一、为什么不能一条 delete 搞定
一张 5000 万行的表,直接执行 delete from orders where created_at < '2024-01-01' 会删除 3000 万行。这条语句在 innodb 引擎下的行为是:

具体问题汇总:
| 问题 | 原理 | 影响 |
|---|---|---|
| 长时间锁表 | 大批量 delete 持有行锁/间隙锁 | 其他读写请求被阻塞,业务不可用 |
| 主从延迟 | 巨量 binlog 集中写入 relay log | 从库落后主库数分钟甚至数小时 |
| 事务膨胀 | undo log + redo log 暴增 | 磁盘空间打满,甚至触发 oom |
| 超时中断 | 客户端/代理层有查询超时限制 | 大事务被中途打断,数据处于中间态 |
| 影响优化器 | 统计信息剧烈波动 | 查询计划可能变差,影响线上查询 |
正确的做法是分批删除:每次只删一小批(如 5000~50000 条),每批之间暂停一段时间,让数据库有时间处理其他请求和同步从库。
二、操作前安全检查清单
在执行任何删除操作之前,逐项确认以下条件。跳过任何一项都可能导致生产事故。

2.1 确认待删数据量
-- 查看有多少数据需要删除 select count(*) as total_to_delete from orders where created_at < date_sub(now(), interval 1 year);
记录这个数字,后续用来验证删除进度。如果总量超过表总行数的 50%,考虑使用 create table ... select 保留新数据 + rename table 的方案(详见方案四)。
2.2 确认索引
分批删除的 where 条件必须走索引,否则每批都会全表扫描:
-- 检查 created_at 上是否有索引 show index from orders where column_name = 'created_at';
如果没有索引,需要先添加。大表加索引建议在低峰期使用 pt-online-schema-change:
pt-online-schema-change \ --alter "add index idx_created_at (created_at)" \ d=your_db,t=orders \ --execute
2.3 确认备份
-- 至少确认最近的备份时间点 show variables like 'log_bin%'; -- 确认 binlog 是否开启(用于 point-in-time recovery) show variables like 'log_bin';
2.4 确认执行窗口
| 检查项 | 说明 |
|---|---|
| 当前 tps/qps | 在低峰期执行,show global status like 'queries' |
| 主从延迟 | show slave status 确认 seconds_behind_master = 0 |
| 磁盘空间 | df -h 确认 undo/redo log 所在分区有足够余量 |
| 业务通知 | 提前通知相关团队,预留应急回滚窗口 |
三、四种方案对比
不同场景适合不同方案,下表给出完整对比:
| 维度 | 存储过程 | pt-archiver | 应用层分批 | 分区表 drop |
|---|---|---|---|---|
| 适用场景 | 一次性清理 | 定期归档清理 | 业务逻辑内 | 按时间分区 |
| 需要装工具 | 否 | 是(percona toolkit) | 否 | 否(需提前设计) |
| 对主从影响 | 可控(调 sleep) | 最小(内置保护) | 可控 | 几乎无影响 |
| 空间回收 | 需额外 optimize | 自动(delete 模式) | 需额外处理 | 自动释放 |
| 操作复杂度 | 低 | 中 | 中 | 低(需提前建分区) |
| 可中断恢复 | 重新 call 即可 | 天然支持(幂等) | 天然支持 | 不支持(drop 不可逆) |
| 推荐度 | 4/5 | 5/5 | 3/5 | 5/5(有分区时) |
选型决策树

四、方案一:存储过程分批删除(推荐快速上手)
这是最简单直接的方案,不需要安装任何额外工具,在 navicat 或任何 mysql 客户端中即可执行。
4.1 完整存储过程
delimiter $$
drop procedure if exists batch_delete_old_data$$
create procedure batch_delete_old_data(
in p_table varchar(64), -- 表名
in p_condition varchar(255), -- 删除条件
in p_batch int, -- 每批删除条数
in p_sleep decimal(3,1) -- 批间暂停秒数
)
begin
declare v_rows int default 1;
declare v_total bigint default 0;
declare v_sql text;
-- 动态拼接 sql,支持任意表和条件
set v_sql = concat('delete from ', p_table,
' where ', p_condition,
' limit ', p_batch);
while v_rows > 0 do
set @stmt = v_sql;
prepare stmt from @stmt;
execute stmt;
deallocate prepare stmt;
set v_rows = row_count();
set v_total = v_total + v_rows;
-- 每删除 10 万条输出一次进度
if v_total % 100000 < p_batch then
select concat('已删除 ', v_total, ' 条') as progress;
end if;
do sleep(p_sleep);
end while;
select concat('删除完成,共删除 ', v_total, ' 条记录') as result;
end$$
delimiter ;
4.2 调用示例
-- 场景:删除 orders 表中一年前的数据,每批 1 万条,暂停 0.5 秒
call batch_delete_old_data(
'orders',
'created_at < date_sub(now(), interval 1 year)',
10000,
0.5
);
预期输出:
+---------------------+ | progress | +---------------------+ | 已删除 100000 条 | +---------------------+ ...(中间多轮输出)... +------------------------------------------+ | result | +------------------------------------------+ | 删除完成,共删除 45820000 条记录 | +------------------------------------------+
4.3 执行流程图

4.4 关键参数调优
| 参数 | 建议值 | 说明 |
|---|---|---|
p_batch | 5000~50000 | 太小效率低,太大锁表时间长。推荐从 10000 开始,观察负载后调整 |
p_sleep | 0.5~2.0 | 有从库时建议 >= 1 秒。单机可以 0.5 秒 |
| 执行时间 | 低峰期 | 避开业务高峰,建议在凌晨 2:00~6:00 执行 |
4.5 navicat 中 delimiter 报错的替代方案
部分 navicat 版本的查询窗口不支持 delimiter 语法。三种替代方式:
- navicat 命令行界面:菜单「工具 -> 命令行界面」,在终端中粘贴 sql
- 外部 mysql 客户端:
mysql -h host -u user -p dbname < procedure.sql - 简化版存储过程:不使用 delimiter,直接在查询窗口中分步执行
五、方案二:pt-archiver(生产环境首选)
pt-archiver 是 percona toolkit 提供的专业归档工具,天然支持分批删除,对主从延迟有内置保护。
5.1 安装
# macos brew install percona-toolkit # ubuntu/debian apt-get install percona-toolkit # centos/rhel yum install percona-toolkit
5.2 基本用法:直接删除
# 删除 orders 表中一年前的数据,每批 5000 条 pt-archiver \ --source d=your_db,t=orders,h=127.0.0.1,u=root,p=your_password \ --where "created_at < date_sub(now(), interval 1 year)" \ --limit 5000 \ --bulk-delete \ --progress 10000 \ --statistics
5.3 进阶用法:归档到另一张表
# 把旧数据归档到 orders_archive 表,而不是直接删除 pt-archiver \ --source d=your_db,t=orders,h=127.0.0.1,u=root,p=your_password \ --dest d=your_db,t=orders_archive \ --where "created_at < date_sub(now(), interval 1 year)" \ --limit 5000 \ --bulk-insert \ --bulk-delete \ --progress 10000
5.4 关键参数说明
| 参数 | 说明 |
|---|---|
--limit | 每批处理的行数,等同于存储过程的 limit |
--bulk-delete | 使用批量 delete 而非逐行删除,效率更高 |
--progress n | 每处理 n 行输出一次进度 |
--statistics | 执行结束后输出统计摘要 |
--check-slave-lag | 指定从库地址,自动检测并等待主从同步 |
--max-lag | 主从延迟超过此秒数时自动暂停(默认 1 秒) |
--dry-run | 只输出将要执行的 sql,不实际执行。强烈建议先用这个测试 |
5.5 pt-archiver vs 存储过程

pt-archiver 的核心优势是从库延迟保护——它会自动检测从库延迟并在延迟过高时暂停,存储过程需要手动调 sleep 时间来间接控制。
六、方案三:应用层分批删除
适合需要长期定期清理的场景,把删除逻辑写在定时任务中。
6.1 python 示例
import pymysql
import time
import logging
logging.basicconfig(level=logging.info, format='%(asctime)s %(message)s')
logger = logging.getlogger(__name__)
def batch_delete(conn_params, table, condition,
batch_size=10000, sleep_seconds=1.0, max_total=0):
"""分批删除历史数据
args:
conn_params: mysql 连接参数字典
table: 表名
condition: where 条件
batch_size: 每批删除条数
sleep_seconds: 批间暂停秒数
max_total: 最多删除条数,0 为不限制
"""
conn = pymysql.connect(**conn_params)
cursor = conn.cursor()
total_deleted = 0
try:
while true:
sql = f"delete from {table} where {condition} limit {batch_size}"
cursor.execute(sql)
conn.commit()
rows = cursor.rowcount
if rows == 0:
break
total_deleted += rows
logger.info(f"本批删除 {rows} 条,累计 {total_deleted} 条")
if max_total > 0 and total_deleted >= max_total:
logger.info(f"已达到 max_total={max_total},停止")
break
time.sleep(sleep_seconds)
except exception as e:
conn.rollback()
logger.error(f"删除中断,已回滚最后一批。累计 {total_deleted} 条。错误: {e}")
raise
finally:
cursor.close()
conn.close()
logger.info(f"删除完成,共删除 {total_deleted} 条记录")
return total_deleted
# 调用示例
batch_delete(
conn_params={
'host': '127.0.0.1',
'user': 'root',
'password': 'your_password',
'database': 'your_db',
},
table='orders',
condition="created_at < date_sub(now(), interval 1 year)",
batch_size=10000,
sleep_seconds=1.0,
)6.2 执行效果
2025-07-06 02:00:01 本批删除 10000 条,累计 10000 条 2025-07-06 02:00:02 本批删除 10000 条,累计 20000 条 2025-07-06 02:00:03 本批删除 10000 条,累计 30000 条 ... 2025-07-06 04:35:12 本批删除 3200 条,累计 45823200 条 2025-07-06 04:35:12 删除完成,共删除 45823200 条记录
6.3 与定时任务集成
# crontab: 每天凌晨 3 点执行
# 0 3 * * * /usr/bin/python3 /opt/scripts/clean_old_orders.py
# 或者用 apscheduler 在应用内调度
from apscheduler.schedulers.blocking import blockingscheduler
scheduler = blockingscheduler()
@scheduler.scheduled_job('cron', hour=3, minute=0)
def nightly_cleanup():
batch_delete(
conn_params={
'host': '127.0.0.1',
'user': 'app',
'password': '***',
'database': 'prod',
},
table='orders',
condition="created_at < date_sub(now(), interval 1 year)",
batch_size=5000,
sleep_seconds=2.0,
)
scheduler.start()七、方案四:分区表 drop partition(最快最干净)
如果你的表已经按时间做了分区,这是最推荐的方案——drop partition 是元数据操作,瞬间完成,不产生大量 binlog,不影响主从同步。
7.1 前提:表已按时间分区
-- 按月分区的表示例
create table orders (
id bigint auto_increment,
user_id bigint not null,
amount decimal(10,2),
created_at datetime not null,
primary key (id, created_at),
index idx_created_at (created_at)
) engine=innodb
partition by range (year(created_at) * 100 + month(created_at)) (
partition p202301 values less than (202302),
partition p202302 values less than (202303),
-- ... 每月一个分区
partition p202412 values less than (202501),
partition p_future values less than maxvalue
);
7.2 删除操作:一条命令搞定
-- 删除 2023 年 1 月的分区(瞬间完成) alter table orders drop partition p202301; -- 删除多个分区 alter table orders drop partition p202301, p202302, p202303;
7.3 分区方案 vs 分批删除
| 维度 | drop partition | 分批 delete |
|---|---|---|
| 执行速度 | 毫秒级 | 数小时 |
| 主从影响 | 几乎无 | 需控制节奏 |
| 空间回收 | 自动释放 | 需额外 optimize |
| 前提条件 | 表必须已分区 | 无特殊要求 |
| 粒度 | 按分区粒度(月/周) | 任意条件 |
注意: drop partition 不可逆。执行前务必确认分区范围正确,建议先 select count(*) from orders partition (p202301) 验证。
八、删除后:回收磁盘空间
不管用哪种 delete 方案,innodb 删除数据后不会自动释放磁盘空间(数据页标记为可复用,但不归还给操作系统)。如果需要真正释放空间:
8.1 方法一:optimize table
-- 简单直接,但会锁表(mysql 5.6+ 支持 online ddl,锁表时间大幅缩短) optimize table orders;
8.2 方法二:alter table 重建
-- 等效于 optimize,通过重建表释放空间 alter table orders engine=innodb;
8.3 方法三:pt-online-schema-change(生产推荐)
# 不锁表重建,适合生产环境 pt-online-schema-change \ --alter "engine=innodb" \ d=your_db,t=orders \ --execute
8.4 验证空间回收
-- 查看表占用的磁盘空间
select
table_name,
round(data_length / 1024 / 1024, 2) as data_mb,
round(index_length / 1024 / 1024, 2) as index_mb,
round(data_free / 1024 / 1024, 2) as free_mb
from information_schema.tables
where table_schema = 'your_db'
and table_name = 'orders';
data_free 字段反映碎片空间。optimize 前后对比此值,即可确认空间是否回收成功。
九、常见问题与排障
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 存储过程执行数小时没完 | 待删数据量极大(数千万) | 属正常现象。通过监控 sql 确认进度,耐心等待 |
delimiter 语法报错 | navicat 查询窗口不支持 | 改用「工具 -> 命令行界面」或外部 mysql 客户端 |
| 从库延迟告警飙升 | binlog 写入速度超过从库回放速度 | 增大 sleep 时间至 2~5 秒;或改用 pt-archiver 的 --check-slave-lag |
| 删除速度越来越慢 | 越到后面符合条件的行越稀疏 | 属正常现象,不影响最终结果 |
| navicat 查询超时断开 | 存储过程执行时间超过客户端超时限制 | 连接设置中调大超时,或改用 nohup mysql ... & 后台执行 |
lock wait timeout exceeded | 其他事务持有了待删行的锁 | 在低峰期执行;减小 batch_size;排查长事务 |
| 删除后表空间没变小 | innodb delete 不释放磁盘空间 | 执行 optimize table 或 pt-online-schema-change 重建表 |
pt-archiver 报 no index found | where 条件字段没有索引 | 先给条件字段加索引 |
十、总结与选型建议
决策总结

关键原则
- 永远不要一条 delete 删千万行数据——分批 + 暂停是铁律
- 操作前必须做安全检查——确认索引、确认数量、确认备份
- 低峰期执行——凌晨 2:00~6:00 是黄金窗口
- 删除后回收空间——optimize table 或 pt-online-schema-change
- 长期方案考虑分区表——如果经常需要按时间清理,建分区表是一劳永逸的方案
附录
术语表
| 术语 | 含义 |
|---|---|
| binlog | mysql 二进制日志,记录所有数据变更,用于主从复制和数据恢复 |
| undo log | 事务回滚日志,delete 时记录被删行的旧值 |
| redo log | 事务持久化日志,确保崩溃恢复时数据不丢失 |
| 主从延迟 | 从库回放 binlog 的速度落后于主库写入的速度 |
| pt-archiver | percona toolkit 中的归档工具,支持分批删除和归档 |
以上就是mysql中存储过程大表分批删除历史数据的四种方案的详细内容,更多关于mysql大表分批删除历史数据的资料请关注代码网其它相关文章!
发表评论