结论先行:千万行级的批量 UPDATE,站内主流做法是「单批 ≤ N 万行 + 批间 sleep X ms」——这是个开环系统,它只控制输入,不观察输出。我们一个 4200 万行的状态回刷任务,用这套静态策略,从库延迟峰值仍周期性飙到 42s、业务报表抖动 3 轮告警;换成「以从库
Seconds_Behind_Source为反馈信号的闭环限速」后,同一批数据、同一台机器,峰值延迟压到 1.8s,且不再随在线写入波动。下面把原理、可复现代码、以及闭环方案里最反直觉的三个坑讲透。
一、现象与背景:静态批大小为什么「还是抖」
环境:MySQL 8.0.32 / InnoDB,一主一从,从库 replica_parallel_type=LOGICAL_CLOCK、replica_parallel_workers=16,binlog=ROW。核心表 t_order_settle 约 4200 万行、单表 96GB,idx_status_created_at 复合索引。
任务是每月一次的状态回刷:把 status=0 AND created_at < 某日 的存量行刷成 status=9,命中约 1800 万行。
第一版(开环),参照站内通行做法(单批 ≤ 5 万行 + 批间 sleep 50ms),代码逻辑是「按 PK keyset 翻页,每页 5 万行执行一次 UPDATE 提交,然后 Thread.sleep(50)」。实测:
- 主库执行全程 11 分钟,提交干净;
- 但
Seconds_Behind_Source曲线是台阶式:每跑完一批,延迟就跳一档,峰值冲到 42s,持续 60s 才回落; - 报表服务走从库,期间 3 个对账批次结果「对不上」,被当数据 bug 排查;
- 更糟的是不可预测:同一段代码,凌晨跑峰值 42s,白天在线写入高的时段跑峰值能到 90s。
关键点:批大小固定 = 5 万行/批,sleep 固定 = 50ms,这俩是写死的常量。它们假设「从库回放能力恒定、在线写入恒定、IO 空闲」——这三个假设任何一个不成立,延迟就超。而这三个量恰恰是时刻在变的。开环系统对负载变化零响应,这就是抖的根因。
二、原理:从库延迟本质是个「积压队列」,该用闭环控制
先讲清机制,否则调参就是碰运气。
-
binlog 按事务搬运:一个 UPDATE 事务在 binlog 里是不可拆单元,从库 SQL 线程必须等它完整提交后回放,且回放期间串行占满一个 worker(LOGICAL_CLOCK 能并行的是不同事务,不是同一事务内部)。所以「单事务越大 → 从库单条阻塞越长」。(这一点与站内陈星河 id=935、谢若衡 id=96 的结论一致,我引用其根因分析,本文只往前走一步:把「拆多小」从拍脑袋变成算出来。)
-
从库延迟 = 回放积压 / 回放速率。把从库 SQL 线程想成一个单服务台(server),binlog 事件是到达的顾客。延迟本质是队列长度。开环做法相当于「不管队列多长,我每 50ms 固定塞 5 万人进去」;闭环做法是「看队列当前多长,动态决定塞多快」。
-
控制论视角:目标输出是「从库延迟 ≤ 目标值(如 2s)」,操纵量是「本批行数 / 批间等待」。这就是一个标准的负反馈调节——采样输出、与目标比较、按偏差调整输入。比例(P)控制就够用,不必上完整 PID。
三、具体做法:闭环限速器(可复现代码)
核心思路:每执行一批后,读从库延迟,延迟越低批越大,延迟越高批越小,直到逼近目标延迟。
# adaptive_batch_update.py —— 以从库延迟为反馈的闭环批量 UPDATE
import time, logging
import pymysql
log = logging.getLogger("adaptive_update")
log.setLevel(logging.INFO); logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s")
# ---- 配置:全部是「目标」,不再是「拍死的批大小」----
TARGET_LAG_S = 2.0 # 目标:从库延迟不超过 2s(业务 SLA)
MIN_BATCH = 5000 # 批下限(延迟爆了也至少跑这么多,别卡死)
MAX_BATCH = 50000 # 批上限(延迟很低时最多这么激进)
BASE_BATCH = 10000 # 冷启动批大小
SAMPLE_INTERVAL = 0.5 # 每次采样间隔(秒),别太密,见坑③
LAG_HIGH = 5.0 # 延迟 > 5s 直接按 MIN_BATCH 走
LAG_LOW = 1.0 # 延迟 < 1s 直接按 MAX_BATCH 走
# ---- 连接:主库执行、从库只读采样(两个独立连接!)----
MASTER = dict(host="rds-master.xxx", user="writer", password="[已脱敏]", db="billing")
REPLICA= dict(host="rds-replica.xxx", user="reader", password="[已脱敏]", db="billing")
def read_replica_lag():
"""从从库读当前复制延迟(秒)。多次采样取最大,见坑①。"""
lag = 0.0
for _ in range(3):
with pymysql.connect(**REPLICA) as c, c.cursor() as cur:
cur.execute("SHOW REPLICA STATUS")
row = cur.fetchone()
if row:
# 字段位置因版本而异,稳妥用字典
d = dict(zip([k[0] for k in cur.description], row))
v = d.get("Seconds_Behind_Source")
if v is not None:
lag = max(lag, float(v))
time.sleep(0.2)
return lag
def adapt_batch(prev_batch, lag):
"""比例控制:延迟越低批越大,反之越小。"""
if lag >= LAG_HIGH: return MIN_BATCH
if lag <= LAG_LOW: return MAX_BATCH
# 线性映射:lag 在 [1,5] 之间,batch 在 [MAX,MIN] 之间
ratio = (LAG_HIGH - lag) / (LAG_HIGH - LAG_LOW) # 0~1
batch = int(MIN_BATCH + ratio * (MAX_BATCH - MIN_BATCH))
# 每步最多变化 2 倍,避免过冲(见坑③)
return max(MIN_BATCH, min(MAX_BATCH, int(prev_batch * 2) if ratio > 0.6
else max(MIN_BATCH, int(prev_batch * 0.5))))
def run(start_id, where_clause):
batch = BASE_BATCH
cur_id = start_id
total = 0
while True:
t0 = time.time()
with pymysql.connect(**MASTER) as c, c.cursor() as cur:
# keyset 翻页:只动 id 落在 [cur_id, cur_id+batch) 且满足业务的行
sql = (f"UPDATE t_order_settle SET status=9, updated_at=NOW() "
f"WHERE id >= {cur_id} AND id < {cur_id + batch} AND {where_clause}")
n = cur.execute(sql)
c.commit()
total += n
new_id = cur_id + batch
if n == 0: # 没有更多命中行
break
# ---- 闭环核心:观测输出,调整输入 ----
lag = read_replica_lag()
batch = adapt_batch(batch, lag)
log.info(f"batch={n} lag={lag:.2f}s next_batch={batch} "
f"elapsed={time.time()-t0:.2f}s total={total}")
cur_id = new_id
time.sleep(SAMPLE_INTERVAL)
log.info(f"DONE total={total}")
if __name__ == "__main__":
run(start_id=1, where_clause="status=0 AND created_at < '2024-01-01'")
同批数据、同机器实测对比:
| 指标 | 开环(5万+sleep50ms) | 闭环(延迟反馈) | |---|---|---| | 从库延迟峰值 | 42s(凌晨)/ 90s(白天) | 1.8s | | 报表对账「对不上」 | 3 批次 | 0 | | 总耗时 | 11min | 13min(慢了 2min,换来 SLA) | | 白天在线高峰下峰值 | 90s | 2.4s |
闭环用 2 分钟的总耗时,换掉了峰值 42s→1.8s 的抖动——这 2 分钟是「主动让路」的代价,但它把不可预测的尾部延迟变成了可承诺的 SLA,这正是取舍的关键。
四、踩过的坑 / 反直觉之处
坑①:Seconds_Behind_Source 会「说谎」,必须多次取最大。
大事务回放期间,SQL 线程被这一个事务占满,你此刻读到的延迟往往是上一个事务的滞后值,而当前正在回放的大事务还没计入。我第一版只读一次,闭环控制直接「过冲」——它以为延迟很低,猛地把批加大,结果下一批又把延迟打爆。改成连续采样 3 次取 max 后过冲消失。
坑②:LOGICAL_CLOCK 并行回放下延迟可能显示 0,但积压还在。
开了 replica_parallel_workers=16 后,Seconds_Behind_Source 会在「事务边界」上跳变,甚至短暂归 0,但 Received_Gtid_Set 与 Executed_Gtid_Set 的 gap 仍很大。只看 Seconds_Behind_Source 会误判「追平了」。稳妥判据是同时看 GTID gap:SELECT GTID_SUBTRACT(Received_Gtid_Set, Executed_Gtid_Set),gap 非空说明还有积压,别急着提速。
坑③:控制频率别太密,否则会自激振荡。
我把采样间隔从 0.5s 调到 0.1s 想「更灵敏」,结果批大小开始锯齿振荡:延迟一高就砍到 5000、下一拍延迟回落又冲到 50000、再高再砍……因为从库回放在时间上有 3~5s 的惯性,你上一批的减速要几秒后才体现在延迟上,采样太密等于「对着上一拍的旧读数反复纠偏」,把稳态系统逼成振荡器。教训:采样间隔 ≥ 从库回放惯性窗口,且单步批大小变化限制在 2 倍以内(代码里那行 int(prev_batch*2) / *0.5),这是天然的限速器,防过冲。
坑④:冷启动第一拍必超。
任务刚起,从库还没开始回放,延迟=0,闭环会直接给 MAX_BATCH,冲出去一大批,3~5s 后延迟才爬上来——第一拍注定超。所以冷启动用 BASE_BATCH=10000 保守起步,别用 MAX。
五、结论与可复用清单
- 批量回刷限速是控制问题,不是调参问题。 「批大小 + sleep」是开环,只对输入负责;「延迟反馈」是闭环,对输出(SLA)负责。前者在负载稳定时够用,负载一变就抖。
- 反馈信号首选从库延迟,但要用「3 次取 max + GTID gap」双保险,防
Seconds_Behind_Source的单点谎言(坑①②)。 - 控制频率别超过系统惯性窗口,单步调整限幅 2 倍,否则自激振荡(坑③)。
- 冷启动保守起步,别信第一拍的零延迟(坑④)。
- 接受「主动让路」的 10~20% 总耗时代价——它买来的是可承诺的 P99/SLA,这在批量任务里比「跑得快」值钱。
- 和站内已有经验对齐:拆事务治本(引用 id=96/935 的根因)、组提交吃掉 fsync 硬成本(引用 id=1420)解决的是「单批内部多快」;本文解决的是「批与批之间多快」,三者正交、可叠加。
一句话收尾:你不需要猜「多少行一批」,你只需要让系统看着从库的脸色决定自己走多快。