先给结论:chunk 大小和间隔不是拍出来的,是从 purge 稳态消费速率反推出来的——核心公式是 DELETE_rate ≤ purge_consume_rate × safety_factor,其中 safety_factor 建议取 0.5(留 50% 余量给白天 OLTP 写入)。下面按你的四个问题逐条拆。
一、chunk 大小 × 批次间隔的定量推导
1.1 先量出 purge 的稳态消费速率
purge 每线程的稳态吞吐(单位:undo records/s)可以用下面方法实测:
-- 连续采 60s,间隔 1s
SELECT UNIX_TIMESTAMP(),
VARIABLE_VALUE AS purge_rows_removed
FROM performance_schema.status_by_thread
WHERE THREAD_NAME = 'purge coordinator'
AND VARIABLE_NAME = 'Innodb_purge_rows_removed';
-- 更精确:采 Innodb_buffer_pool_pages_purge
SELECT UNIX_TIMESTAMP(), VARIABLE_VALUE
FROM performance_schema.global_status
WHERE VARIABLE_NAME IN ('Innodb_purge_rows_removed','Innodb_buffer_pool_pages_purge');
你的数据点:4 purge 线程时 History list 峰值 280 万、12 分钟回落 → 稳态消费速率 ≈ 2,800,000 / 720s ≈ 3,890 rows/s(这是含白天 1200 QPS 写入的净余量)。8 线程时峰值 190 万 → ≈ 8,500 rows/s。
1.2 反推安全 chunk
安全 DELETE 速率 = purge_consume_rate × 0.5
= 8500 × 0.5 = 4,250 rows/s(8 线程)
单批 chunk = 4,250 × batch_duration
batch_duration = 0.5s(执行时间)→ chunk ≈ 2,125 行
批次间隔 = 总间隔 - 执行时间
= (1 / 4250) × 1000ms - 500ms ≈ 735ms
但你实测 50k × 200ms 跑了 3h40m,而 10k × 500ms 要 6h+——说明你的瓶颈不在 purge 消费速率,而在 二级索引的 purge 扇出:6 个二级索引意味着每删 1 行主表要删 6 行二级索引记录,undo 量 ×6。
修正公式:
有效 purge 消耗 = DELETE_rate × (1 + 二级索引数) × avg_undo_per_record
= 4250 × (1 + 6) × ~80B
= 4250 × 7 × 80 = 2.38 MB/s(undo 产生速率)
所以安全 chunk = 2,000~3,000 行/批,间隔 500ms~1s,总时长 ≈ 3.2亿 / 2500 × 0.75s ≈ 9 小时(比你现在的 3h40m 慢,但 History list 峰值能压到 30 万以下)。
1.3 如果窗口只有 3h
两条路:
- 并行化:按
id范围切成 4 段,4 个会话并行 DELETE(每会话 2k/500ms),总速率 ×4,但要注意行锁竞争——按 id 范围切片天然无锁冲突; - 分时段:02:00-05:00 删主表,05:00-08:00 删二级索引(
ALTER TABLE ... DROP INDEX+ 重建),把 purge 压力摊到两个窗口。
二、「purge 追得上」的实时监控判据
2.1 核心指标(按优先级排序)
| 指标 | 来源 | 安全阈值 | 告警阈值 | 说明 |
|------|------|---------|---------|------|
| Innodb_history_list_length | SHOW ENGINE INNODB STATUS 或 performance_schema.global_status | < 50 万 | > 100 万 | 最核心指标,直接反映版本链长度 |
| Innodb_purge_rows_removed delta/10s | performance_schema.global_status 两次采样差 | > 1000/s | < 200/s | purge 实际消费速率,低于 200 说明追不上 |
| Innodb_buffer_pool_pages_purge delta/10s | 同上 | > 50/s | < 10/s | 页级 purge 速率 |
| 二级索引 Data_free 增速 | information_schema.INNODB_TABLESPACES | < 1GB/h | > 5GB/h | 碎片化速度 |
| 点查 P99(业务侧) | 你的 APM | < 10ms | > 20ms | 最终业务影响 |
2.2 告警规则(Prometheus 伪代码)
# 批删窗口内专用告警(02:00-05:00 生效)
- alert: UndoHistoryCritical
expr: innodb_history_list_length > 1000000
for: 30s
labels: {severity: P1}
annotations:
summary: "undo history list 超过 100 万,点查 P99 即将退化"
runbook: "立即暂停批删,等待 purge 追赶"
- alert: UndoHistoryWarning
expr: innodb_history_list_length > 500000
for: 60s
labels: {severity: P2}
annotations:
summary: "undo history list 超过 50 万,趋势观察"
- alert: PurgeLagging
expr: |
(increase(innodb_purge_rows_removed[10s]) / 10) < 200
and innodb_history_list_length > 200000
for: 30s
labels: {severity: P1}
annotations:
summary: "purge 消费速率低于 200 rows/s 且 history list > 20 万"
2.3 自动化保护(推荐)
在批删脚本里加一个自适应限速逻辑:
#!/bin/bash
# adaptive_delete.sh
CHUNK=2000
SLEEP_MS=500
MAX_HISTORY=500000
while [ $CHUNK -gt 500 ]; do
# 执行一批
mysql -e "DELETE FROM t_payment_flow WHERE id BETWEEN $CUR AND $CUR+$CHUNK LIMIT $CHUNK"
# 等 sleep
sleep $SLEEP_MS/1000
# 检查 history list
HLL=$(mysql -N -e "SHOW GLOBAL STATUS LIKE 'Innodb_history_list_length'" | awk '{print $2}')
if [ $HLL -gt $MAX_HISTORY ]; then
# 指数退避:chunk 减半,sleep 加倍
CHUNK=$((CHUNK / 2))
SLEEP_MS=$((SLEEP_MS * 2))
echo "[WARN] HLL=$HLL > $MAX_HISTORY, reducing chunk to $CHUNK, sleep to ${SLEEP_MS}ms"
fi
CUR=$((CUR + CHUNK))
done
核心判据:HLL 增速 > purge 消费速率 × 2 时自动降速,HLL < 50 万 时恢复全速。
三、二级索引碎片化治理
3.1 实时观测
-- 批删期间每 5 分钟采一次
SELECT
t.TABLE_NAME,
i.INDEX_NAME,
t.DATA_FREE,
(t.DATA_FREE - @prev_free) AS delta_bytes
FROM information_schema.TABLES t
JOIN information_schema.STATISTICS i ON t.TABLE_NAME = i.TABLE_NAME
WHERE t.TABLE_SCHEMA = 'your_db' AND t.TABLE_NAME = 't_payment_flow';
经验阈值:Data_free > 表总大小 × 30% 时点查 P99 开始退化(InnoDB 的 B-tree 分裂/合并开销增大)。
3.2 治理策略(推荐分步走)
| 阶段 | 操作 | 时机 | 影响 |
|------|------|------|------|
| 批删中 | 不做任何 DDL | - | - |
| 批删后 1h | ANALYZE TABLE t_payment_flow | 06:00 | 无锁,更新统计信息 |
| 批删后 2h | 对 Data_free > 30% 的索引执行 ALTER TABLE ... ALTER INDEX ... ALGORITHM=INPLACE | 06:00-08:00 | 在线 DDL,但会占用 IO 带宽,建议限流 |
| 每周 | 全表 OPTIMIZE TABLE(或重建 .ibd) | 周日 02:00 | 离线操作,需业务窗口 |
关键取舍:6 个索引全重建 vs 只重建 Data_free 最大的 2-3 个。建议只重建 Data_free > 20% 的,其余等下一轮。
四、更轻量的归档路径
4.1 按主键范围切片(推荐,最轻量)
-- 替代 WHERE created_at < '2025-01-01'
-- 用主键范围避免二级索引扫描
DELETE FROM t_payment_flow
WHERE id BETWEEN 1000000 AND 1002000
AND created_at < '2025-01-01'
LIMIT 2000;
优势:走主键 B-tree,不触发二级索引扫描,undo 量减少约 40%(因为不需要更新 6 个二级索引的"存在性"标记)。
4.2 DROP PARTITION(最彻底但 DDL 代价高)
-- 一次性 DDL(3.2 亿行)
ALTER TABLE t_payment_flow
PARTITION BY RANGE (created_at) (
PARTITION p2024 VALUES LESS THAN ('2025-01-01'),
PARTITION p2025 VALUES LESS THAN ('2026-01-01')
);
-- 归档时
ALTER TABLE t_payment_flow DROP PARTITION p2024;
DDL 代价估算:
- 3.2 亿行 + 6 索引 → Online DDL 约 4-8 小时(取决于 IO 带宽)
- 期间写 QPS 1200 会正常执行,但 P99 可能涨 2-5ms(MDL 等待)
- 建议:在业务低峰(02:00-06:00)执行,提前 1 天通知业务方
长期收益:每年归档从"3 小时 DELETE"变成"1 分钟 DROP PARTITION",purge 压力归零。
五、一句话总结
chunk 大小 = purge 稳态消费速率 / (1 + 二级索引数) / 2,用 Innodb_history_list_length > 50 万 做硬阈值、purge_rows_removed delta < 200/s 做软阈值,批删脚本里加自适应降速逻辑。如果窗口不够,按主键切片并行删 + 只重建 Data_free > 30% 的索引。长期看,DROP PARTITION 是最干净的归档路径,一次 DDL 换每年 1 分钟归档。