批量 UPDATE 10w 行怎么做(分批大小 / Binlog 对主从延迟的影响)
一句话速记
不要一次 UPDATE 10w 行:会产生一个巨大事务(长时间持锁 + 巨量 Binlog),导致主从延迟飙升、锁表影响业务。正确做法是分批处理:每批 500~2000 行,每批 commit 后 sleep 几十毫秒,让从库追上,再处理下一批。分批依据:按主键 ID 范围分段(WHERE id BETWEEN ? AND ?),避免 OFFSET 扫描。
关键细节
1)一次 UPDATE 10w 行的危害
1. 长事务 → 长时间持 Row Lock(其他写操作被阻塞)
2. Undo Log 膨胀(MVCC 版本链积累)
3. Binlog 产生巨大事务(10w 行的 ROW Binlog)
4. 从库回放 Binlog 是串行的(MySQL 5.6 以前完全串行)
→ 从库回放时间 >> 主库执行时间 → 主从延迟剧增
5. 如果 MySQL 崩溃,事务回滚时间极长(Undo Log 太大)
2)分批 UPDATE 的标准写法
-- 正确:按主键范围分批(效率最高)
SET @batch_size = 1000;
SET @start_id = 0;
LOOP:
UPDATE orders
SET status = 2
WHERE id > @start_id AND id <= @start_id + @batch_size
AND status = 1;
SET @start_id = @start_id + @batch_size;
-- 每批后 sleep,让从库追上
DO SLEEP(0.05); -- 50ms
IF @start_id > (SELECT MAX(id) FROM orders) THEN
BREAK;
END IF;更实用的脚本写法(Shell + MySQL):
#!/bin/bash
BATCH=1000
START=0
MAX=$(mysql -e "SELECT MAX(id) FROM orders" -ss)
while [ $START -lt $MAX ]; do
END=$((START + BATCH))
mysql -e "UPDATE orders SET status=2 WHERE id > $START AND id <= $END AND status=1"
echo "Updated $START to $END"
START=$END
sleep 0.05 # 50ms
doneJava 业务代码中的分批:
@Transactional(propagation = Propagation.NOT_SUPPORTED) // 不用大事务
public void batchUpdate() {
int batchSize = 1000;
long startId = 0;
long maxId = orderMapper.getMaxId();
while (startId < maxId) {
long endId = startId + batchSize;
int affected = orderMapper.batchUpdateByIdRange(startId, endId);
log.info("Updated {} rows, range [{}, {}]", affected, startId, endId);
startId = endId;
try {
Thread.sleep(50); // 50ms sleep
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}3)为什么按 ID 范围而不是 OFFSET 分批
-- ❌ 错误:OFFSET 分批(越来越慢)
UPDATE orders SET status=2 WHERE status=1 LIMIT 1000 OFFSET 0;
UPDATE orders SET status=2 WHERE status=1 LIMIT 1000 OFFSET 1000;
-- 第 N 批需要扫描前 N*1000 行 → O(N²) 复杂度,最后几批极慢
-- ✅ 正确:ID 范围分批(O(N))
UPDATE orders SET status=2 WHERE id BETWEEN 1 AND 1000 AND status=1;
UPDATE orders SET status=2 WHERE id BETWEEN 1001 AND 2000 AND status=1;
-- 每批只扫描对应 ID 区间,效率稳定如果没有单调递增 ID(如按时间范围):
-- 按时间范围分批(注意:覆盖同一批的 status 改变会导致跳过)
-- 更安全:先查出 ID,再按 ID 批量更新
SELECT id FROM orders WHERE status=1 AND created_at < '2024-01-01' LIMIT 1000;
-- 拿到 id 列表,再 UPDATE WHERE id IN (...)4)Binlog 对主从延迟的影响
Binlog 类型对主从延迟的影响:
Statement-Based Replication (SBR):
记录 SQL 语句
批量 UPDATE: 一条 SQL → Binlog 很小
但可能主从不一致(非确定性函数,RC 隔离级别下)
Row-Based Replication (RBR):
记录每行的变化(before + after 镜像)
批量 UPDATE 10w 行 → Binlog 中 10w 条行变化记录 → Binlog 极大
从库回放:读取每条行变化,逐行应用 → 时间长 → 延迟大
从库并行复制(解决延迟的关键):
MySQL 5.6:基于 Schema 的并行复制(不同库并行)
MySQL 5.7:基于 Logical Clock 的并行复制(同一 Group Commit 的事务并行)
MySQL 8.0:WRITESET 复制(不冲突的事务并行,更优)
分批 UPDATE 的好处:
每批是独立小事务 → 主库 Group Commit 可以并发提交
→ 从库并行回放(不同批次无冲突)
→ 大幅减少主从延迟
5)实际场景:online DDL vs pt-online-schema-change
大表加字段/加索引的选择:
MySQL 5.6+ Online DDL:
ALTER TABLE t ADD COLUMN c INT DEFAULT 0;
→ 不阻塞 DML(读写),但会加元数据锁(MDL)
→ 期间产生大量 row log,完成时合并 → 影响写性能
→ 适合:中小表(< 5 亿行)
pt-online-schema-change(pt-osc):
创建影子表 → 增量同步 → 原子切换
→ 分批复制数据,控制速度,主从延迟低
→ 适合:超大表、对主从延迟敏感的场景
gh-ost(GitHub Online Schema Tool):
读 Binlog 而非触发器 → 对主库压力更小
→ 适合:高并发大表,对主库压力敏感
延伸追问
- Q:分批 UPDATE 中途失败了怎么办?
→ 因为每批是独立事务,失败只影响当前批次,重新从失败的 ID 范围继续即可。记录最后成功的
start_id(写到 Redis 或配置文件),重启时从这里继续。这是幂等性设计——UPDATE ... WHERE status=1对已更新的行无效。 - Q:分批 UPDATE 期间,业务还在写入新数据,会漏掉吗?
→ 取决于分批策略。如果按当前最大 ID 分段,之后新插入的行(更大 ID)不在范围内,确实漏掉。需要在分批结束后再做一次全量扫描(
WHERE status=1 AND id <= snapshot_max_id),或者分批结束后跑一次UPDATE覆盖新增范围。 - Q:如何监控主从延迟?
→
SHOW SLAVE STATUS\G看Seconds_Behind_Master(不够精准);更精准:Percona PMM、Prometheus + mysqld_exporter(mysql_slave_lag_seconds指标)。或用 Heartbeat 表(pt-heartbeat):主库定时写时间戳,从库读时间戳差值即为真实延迟。
我的记法
- 分批核心:按主键 ID 范围,每批 500~2000,批间 sleep 50ms
- 不用 OFFSET(越来越慢),不用一次性大事务(延迟 + 锁)
- Binlog ROW 格式下,10w 行 = 10w 条 Binlog 记录 → 从库回放慢
- 分批 → 小事务 → 从库并行回放 → 延迟可控
- 一句话:「大批量更新 = 切成 1000 行一批 + 批间 sleep,让从库喘气」
状态
- 已背速记
- 能写分批更新的 SQL/代码框架
- 能解释 Binlog 对主从延迟的影响