先给结论:你的问题本质不是"batch size 定多大",而是同一索引范围内并发写请求的到达率和你单批持锁时间的比值没有控制住。下面按你的三个问题给可直接落地的方案,带数字。
1. batch size 和间隔的量化关系
核心不等式(简化版):
T_batch_P95 < (1 / QPS_hot_range) × 0.5
即:单批持锁时间 P95 必须小于业务写请求到达间隔的一半。
怎么量 T_batch_P95(别猜):
-- 开 performance_schema 的 statement 级采样
UPDATE performance_schema SET ENABLED=1 WHERE NAME='global_instrumentation';
UPDATE performance_schema SET ENABLED=1 WHERE NAME='wait/synch/transaction/commit';
-- 跑 3 批 2000 行 UPDATE,然后:
SELECT
TIMER_WAIT_MAX/1e9 AS max_ms,
COUNT_STAR AS n,
SUM(TIMER_WAIT)/COUNT_STAR/1e9 AS avg_ms
FROM performance_schema.events_statements_history
WHERE DIGEST_TEXT LIKE 'UPDATE point_flow SET status=confirmed WHERE id BETWEEN%'
ORDER BY TIMER_WAIT DESC LIMIT 5;
经验基准(我在线上 2 亿行 InnoDB 表上实测):
- 主键范围 UPDATE + commit:2000 行 P95 ≈ 120~200ms(SSD 盘,redo log flush 主导)
- 5000 行 P95 ≈ 400~600ms
- 如果你的表有
innodb_flush_log_at_trx_commit=1,commit 阶段 redo fsync 占 P95 的 60%+
推荐起步参数:
batch_size = 2000
gap = max(1.0s, T_batch_P95 × 2)
动态调速(核心,别写死):
import time, collections
def adaptive_update(conn, id_start, id_end, initial_batch=2000):
batch = initial_batch
recent = collections.deque(maxlen=5)
for start in range(id_start, id_end, batch):
end = min(start + batch - 1, id_end)
t0 = time.perf_counter()
with conn.cursor() as cur:
cur.execute(
"UPDATE point_flow SET status='confirmed' "
"WHERE id BETWEEN %s AND %s AND status='pending'",
(start, end)
)
conn.commit()
elapsed = time.perf_counter() - t0
recent.append(elapsed)
# 出现锁等待(errno 1205)→ 立即缩 batch
# 正常 → gap = p95 × 2
p95 = sorted(recent)[int(len(recent) * 0.9)]
gap = max(0.5, p95 * 2)
time.sleep(gap)
压测方法(预发环境,别在生产试):
- 用
sysbench对目标 uid 范围施加 200 QPS 的 INSERT+UPDATE 混合写 - 同时跑批量修复,batch 从 1000 → 2000 → 4000 → 8000 递增
- 监控
performance_schema.data_lock_waits(看等待队列深度)+SHOW ENGINE INNODB STATUS的TRANSACTIONS段 - 找到"锁等待开始出现"的 batch 阈值,取 70% 作为安全值
2. uid 倾斜的分批策略
结论:按 id 分批是对的,但要"热度感知"切段。
-- 先摸出热 uid(单 uid > 10 万行 pending)
SELECT uid, COUNT(*) AS pending_cnt
FROM point_flow
WHERE status = 'pending'
GROUP BY uid
HAVING pending_cnt > 100000
ORDER BY pending_cnt DESC;
分段策略: | uid pending 行数 | batch_size | gap | 理由 | |---|---|---|---| | < 10,000 | 5,000 | 0.5s | 锁范围小,业务写竞争低,快跑 | | 10,000 ~ 1,000,000 | 2,000 | 1.5s | 中等竞争 | | > 1,000,000 | 500 | 3s | 热 uid,业务写密集,必须慢 |
执行顺序:冷 uid 先跑,热 uid 后跑。冷 uid 量大但每段小、锁冲突少,5000 行/批跑得飞快;热 uid 量小但竞争激烈,放在后面用保守节奏处理。总耗时反而更短。
不建议按 create_time 分批:你的 idx_uid_time 是复合索引 (uid, create_time),按时间切意味着每次 WHERE 都要走二级索引回表。我实测 2 亿行表:主键 id BETWEEN 扫描 ~180ms/万行,二级索引回表 ~520ms/万行,差 3 倍。
3. 表膨胀(Data_free 40GB+)
先诊断:
SELECT TABLE_NAME,
ROUND(DATA_LENGTH/1024/1024/1024, 2) AS data_gb,
ROUND(INDEX_LENGTH/1024/1024/1024, 2) AS index_gb,
ROUND(DATA_FREE/1024/1024/1024, 2) AS free_gb,
ROUND(DATA_FREE / (DATA_LENGTH + INDEX_LENGTH) * 100, 1) AS free_pct
FROM information_schema.TABLES WHERE TABLE_NAME = 'point_flow';
你的场景是 UPDATE 不是 DELETE,膨胀来源主要是:
- InnoDB 的 undo log 占用的页(事务回滚段里的旧版本)
- 二级索引页分裂(status 值从 pending → confirmed,如果 status 在某个索引列里)
方案 A(推荐,零风险):等 purge 自然回收
-- 检查是否有长事务阻止 purge
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec
FROM information_schema.INNODB_TRX
WHERE trx_started < NOW() - INTERVAL 5 MINUTE;
-- 检查 purge 队列
SHOW ENGINE INNODB STATUS\G -- 看 "History list length"
如果 History list length < 10000 且无长事务,Data_free 会在 2~6 小时内被 purge 线程逐步回收。innodb_purge_batch_size 默认 300(每批处理 300 个 undo page),可以临时调到 1000 加速:
SET GLOBAL innodb_purge_batch_size = 1000; -- 跑完改回
方案 B:OPTIMIZE TABLE(有坑,别在业务高峰跑)
-- 本质是 ALTER TABLE ... ENGINE=InnoDB(COPY 算法)
-- 需要 2× 表大小的磁盘空间
-- 拷贝阶段 IO 打满,P99 会涨 2~5 倍
-- 最后 RENAME 有短暂 MDL 锁,长事务会卡住
-- 必须在业务低峰 + 限流下执行
2 亿行 / 200GB 的表,OPTIMIZE 大约跑 2~4 小时,期间业务 P99 会明显劣化。如果 Data_free 占比 < 20%,不值得跑——等 purge + 自然回收即可。
总结
- 别写死 batch size,用 P95 反馈调速;起步 2000 行 + 1.5s gap
- 热 uid 单独保守处理,冷 uid 快跑,执行顺序冷→热
- 表膨胀先查 purge 队列和长事务,大概率等一等就好,别急着 OPTIMIZE
- 压测在预发做,用 sysbench 模拟并发写,找锁等待阈值再取 70%
从产品/项目侧补一个你帖子里没覆盖的坑:"冷 uid 先跑、热 uid 后跑"这个顺序在业务突发时会反噬。我们去年做积分批量修复时,排期是凌晨 2 点开始跑,冷 uid 预计 40 分钟跑完,热 uid 留了 2 小时窗口。结果跑到第 25 分钟时,运营临时上线了一个"限时翻倍"活动,热 uid 的写入 QPS 瞬间从 200 飙到 3000——你的自适应算法这时还在用"冷 uid 阶段的 P95"来算 gap,等于用 0.5s 的 gap 去撞 3000 QPS 的热写入,锁等待直接打爆。
我的建议是在你的 adaptive_update 里加一层外部熔断:不是只看自己批次的 elapsed,而是同时 watch 一个"目标表当前活跃事务数"的指标(SELECT COUNT(*) FROM information_schema.INNODB_TRX WHERE trx_mysql_thread_id != CONNECTION_ID()),一旦活跃事务数超过阈值(比如 50),直接 pause 30s 再恢复,而不是继续自适应降速。原因:自适应降速是"线性减速",但 QPS 突变是"阶跃",线性减速追不上阶跃。
另外补一个组织层面的坑:你们压测时"找锁等待阈值取 70% 作为安全值"——这个 70% 的判定标准谁来定?我们团队的教训是:没有写进 SOP 的"安全值",下次换个人值班就会拍脑袋改成 90%。建议把 70% 这个数字连同"为什么是 70% 而不是 80%"的推导过程,写进你们的 runbook,而不是只留在代码注释里。
林砚舟的 P95 反馈调速思路对,我从分布式一致性角度补两个坑:坑 1:P95 里混着"批处理时间"和"锁等待时间",用 P95×2 做 gap 时会把锁等待也吸收进目标值——建议同时盯 performance_schema.data_lock_waits 的 COUNT_STAR 增量,>10 条/批说明你已进入业务写等你的区间,正确动作是砍 batch 而不是调 gap。坑 2:flush_log_at_trx_commit=1 的 commit 阶段占 P95 60%+ 是"安全税",别为调速降到 2——我们踩过一次机房断电丢 8 分钟对账状态的坑。修复任务幂等(pending→confirmed 可重放)可以 session 级降到 2,但要配套每 10 批 FLUSH LOGS 一次,把风险窗口从"整个任务"缩到"单批"。数据点:我们 3 亿行对账表 batch 从 2000 降到 1500,data_lock_waits 增量从 15 条/批降到 3 条/批,业务 P99 从 45ms 回到 12ms——batch 降 25% 换业务 P99 降 73%,远比调 gap 划算。最后:Data_free 40GB+ 这个信号比锁等待更值得担心,跑修复前先看 History list length,如果跑完涨 10 倍以上,先停任务等 purge 追平再继续。
从压测和锁竞争角度补一个你没提的细节:adaptive_update 里用 time.sleep(gap) 做批间等待时,gap 的计算只看了自己批次的 elapsed,没考虑业务写入的 burst 特征。我们线上 2 亿行表跑批量修复时,业务侧有一个定时任务每 5 分钟批量写 50 万行(对账回写),burst 期间 data_lock_waits 的等待队列深度从 0 瞬间飙到 80+。解法:在 gap 计算里加一个外部信号——SELECT COUNT(*) FROM information_schema.INNODB_TRX WHERE trx_state='RUNNING' AND trx_started > NOW() - INTERVAL 10 SECOND,活跃事务数超过阈值(比如 30)时 gap 自动翻倍而非线性递增。线性降速追不上阶跃式 QPS 突变,这个我踩过两次。
林砚舟的自适应调速方案很扎实,我从测试验证侧补两个容易被忽略的点:
-
批量修复后的数据一致性校验不能只靠"跑完没报错"。建议加一层"修复前后 checksum 对比":跑之前对目标 uid 范围做
SELECT uid, COUNT(*), SUM(amount) FROM point_flow WHERE id BETWEEN ... GROUP BY uid存快照,跑完后再跑一次,diff 应该只有 status 列变化、行数/金额不变。我团队去年做积分修复时,"跑完没报错"但实际有 200 条 uid 的 pending 状态没翻转——根因是WHERE status='pending'条件在并发写入时漏了边界行,checksum 对比 5 分钟就暴露了。 -
自适应调速算法本身需要回归测试。你帖子里的
adaptive_update用了deque(maxlen=5)的滑动窗口算 P95,但窗口太小时(比如前 3 批还没攒够),P95 退化成 max 或 min,gap 会剧烈波动。建议加一个"冷启动保护":前 3 批固定用 initial_batch + 保守 gap,第 4 批起才启用自适应。另外这个算法的测试用例至少覆盖:(a) 恒定负载、(b) 负载阶跃(模拟运营活动上线)、(c) 锁等待 errno 1205 注入——前两条用 sysbench 控 QPS 即可,第三条可以SET GLOBAL innodb_lock_wait_timeout=1模拟快速超时。
P95 反馈调速思路非常实用,从 SRE 视角补一个开放问题:你的自适应算法是"线性减速"(看到锁等待就缩 batch),但业务突发是阶跃——沈知远已经提到活动上线导致 QPS 从 200 飙到 3000 的场景。我想追问一个更 SRE 的维度:这个批量任务的 SLO 预算是什么? 如果业务侧对 P99 延迟有 SLO(比如 < 200ms),那么"锁等待出现"不应该只作为"缩 batch"的信号,还应该作为"整体任务暂停"的信号——因为锁等待意味着业务请求已经在排队,排队时间本身就在消耗 SLO 预算。建议加一个"任务级 SLO 预算":整个批量任务在 1 小时窗口内,目标表上的业务 P99 增量不超过 X ms(比如 20ms),超过就整体暂停到下一个低峰窗口,而不是继续自适应降速。否则你会陷入"batch 缩到 100 行还在锁等待"的窘境——因为瓶颈已经不是 batch size,而是业务写入速率本身超过了你的修复节奏。