作为DBA和开发人员,我们都经历过这样的场景:某个日志表积累了上千万条过期数据需要清理,或者业务要求删除特定时间段的历史记录。当我第一次执行DELETE FROM huge_table WHERE create_time < '2022-01-01'这样的语句时,数据库直接卡死了近半小时,导致线上服务大面积超时——这个惨痛教训让我深刻认识到批量删除海量数据需要特殊处理。
锁表问题是最直接的杀手。MySQL的InnoDB引擎虽然支持行级锁,但在执行大批量删除时,锁会逐步升级。我曾监控到一个删除500万条数据的操作,最终导致了表级锁,阻塞了所有对该表的读写请求超过15分钟。
事务日志膨胀是另一个隐形炸弹。某次我删除一个约300GB的大表数据后,发现binlog突然增长了近200GB,直接导致磁盘报警。这是因为MySQL需要记录所有被删除的行以便回滚和复制。
资源争用问题也不容忽视。在删除过程中,我通过SHOW PROCESSLIST观察到CPU和IO使用率长期保持在90%以上,严重影响了其他正常查询的响应速度。
重要提示:在MySQL 8.0+版本中,虽然对DDL操作有了原子性支持,但DML操作的大事务问题依然存在。我曾经在MySQL 5.7上执行一个删除1亿条记录的操作,最终因为max_allowed_packet限制而失败,但却已经产生了50GB的binlog——这个教训告诉我必须使用分批处理。
这是我在生产环境使用最多的方法,特别适合没有连续主键的表。核心思路是通过LIMIT控制每次删除的记录数,循环执行直到没有数据可删。
sql复制-- 推荐使用存储过程实现自动化
DELIMITER //
CREATE PROCEDURE batch_delete(
IN table_name VARCHAR(100),
IN condition VARCHAR(1000),
IN batch_size INT
)
BEGIN
DECLARE affected_rows INT DEFAULT 1;
WHILE affected_rows > 0 DO
SET @sql = CONCAT('DELETE FROM ', table_name,
' WHERE ', condition,
' LIMIT ', batch_size);
PREPARE stmt FROM @sql;
EXECUTE stmt;
SET affected_rows = ROW_COUNT();
DEALLOCATE PREPARE stmt;
-- 建议每次批处理后稍作停顿
DO SLEEP(0.1);
END WHILE;
END //
DELIMITER ;
-- 调用示例:删除2022年前的日志
CALL batch_delete('access_logs', 'log_time < "2022-01-01"', 5000);
参数选择经验:
避坑指南:
Handler_read_next值,如果持续很高说明索引效率低当表有自增主键或连续主键时,这种方法效率极高。我曾在清理用户历史数据时,用这种方法比LIMIT分批快3倍以上。
sql复制DELIMITER //
CREATE PROCEDURE range_delete(
IN table_name VARCHAR(100),
IN pk_column VARCHAR(50),
IN condition VARCHAR(1000),
IN batch_size INT
)
BEGIN
DECLARE min_id BIGINT;
DECLARE max_id BIGINT;
DECLARE current_id BIGINT;
-- 获取符合条件的最小和最大ID
SET @sql = CONCAT('SELECT MIN(', pk_column, '), MAX(', pk_column, ') INTO @min, @max FROM ', table_name, ' WHERE ', condition);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET min_id = @min;
max_id = @max;
current_id = min_id;
-- 按ID范围分批删除
WHILE current_id <= max_id DO
SET @batch_end = current_id + batch_size - 1;
SET @sql = CONCAT('DELETE FROM ', table_name,
' WHERE ', pk_column, ' BETWEEN ', current_id, ' AND ', @batch_end,
' AND ', condition);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET current_id = current_id + batch_size;
DO SLEEP(0.05); -- 更短的停顿
END WHILE;
END //
DELIMITER ;
性能对比测试(删除1000万条记录):
| 方法 | 耗时 | 锁等待时间 | binlog大小 |
|---|---|---|---|
| 直接删除 | 45分钟 | 38分钟 | 12GB |
| LIMIT分批 | 22分钟 | 累计3分钟 | 12GB |
| ID范围分批 | 7分钟 | 累计45秒 | 12GB |
对于需要频繁执行的数据清理任务,我推荐使用存储过程封装。这是我为某电商平台设计的归档方案:
sql复制DELIMITER //
CREATE PROCEDURE archive_old_orders(IN cutoff_date DATE)
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE rows_affected INT;
DECLARE start_time DATETIME DEFAULT NOW();
DECLARE batch_count INT DEFAULT 0;
-- 记录开始日志
INSERT INTO archive_logs(job_name, start_time)
VALUES ('archive_old_orders', start_time);
-- 使用事务确保每批处理的原子性
WHILE NOT done DO
START TRANSACTION;
DELETE FROM orders
WHERE order_date < cutoff_date
LIMIT 1000;
SET rows_affected = ROW_COUNT();
SET batch_count = batch_count + 1;
IF rows_affected = 0 THEN
SET done = TRUE;
ELSE
-- 将删除的数据归档到历史表
INSERT INTO orders_archive
SELECT * FROM orders
WHERE order_date < cutoff_date
LIMIT 1000;
COMMIT;
DO SLEEP(0.2); -- 控制处理节奏
END IF;
END WHILE;
-- 记录完成日志
UPDATE archive_logs
SET end_time = NOW(),
batches = batch_count,
rows_processed = (batch_count - 1) * 1000 + rows_affected
WHERE job_name = 'archive_old_orders'
AND start_time = start_time;
END //
DELIMITER ;
关键设计点:
当需要删除表中大部分数据(如超过70%)时,创建新表替换旧表往往是最佳选择。我在处理一个包含5亿条记录的日志表时,这种方法比删除快10倍。
详细操作步骤:
sql复制CREATE TABLE new_logs LIKE logs;
sql复制-- 使用INSERT IGNORE避免唯一键冲突
INSERT IGNORE INTO new_logs
SELECT * FROM logs
WHERE create_time >= '2023-01-01';
sql复制ALTER TABLE new_logs ADD INDEX (user_id);
ALTER TABLE new_logs ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id);
sql复制RENAME TABLE logs TO old_logs, new_logs TO logs;
sql复制-- 先确认新表正常工作
SELECT COUNT(*) FROM logs;
-- 然后删除
DROP TABLE old_logs;
注意事项:
对于按月分区的日志表,直接删除整个分区是最优解:
sql复制-- 查看分区定义
SHOW CREATE TABLE partitioned_logs;
-- 删除特定月份分区
ALTER TABLE partitioned_logs
DROP PARTITION p_202201;
性能优势:
当表有外键引用时,我推荐以下模式:
sql复制-- 1. 先禁用外键检查
SET FOREIGN_KEY_CHECKS = 0;
-- 2. 按主键范围分批删除子表数据
CALL range_delete('child_table', 'id', 'parent_id IN (SELECT id FROM parent_table WHERE create_time < "2020-01-01")', 1000);
-- 3. 再删除父表数据
CALL batch_delete('parent_table', 'create_time < "2020-01-01"', 1000);
-- 4. 重新启用外键检查
SET FOREIGN_KEY_CHECKS = 1;
对于特别大的表,Percona的pt-archiver是更好的选择。这是我常用的命令模板:
bash复制pt-archiver \
--source h=localhost,D=db,t=big_table \
--where "created_at < '2021-01-01'" \
--purge \
--limit 1000 \
--sleep 0.5 \
--statistics \
--no-check-charset
优势:
在执行批量删除时,我通常会监控这些指标:
sql复制-- 查看当前活动进程
SHOW PROCESSLIST;
-- InnoDB状态
SHOW ENGINE INNODB STATUS;
-- 锁等待情况
SELECT * FROM performance_schema.events_waits_current;
-- 监控删除进度
SELECT COUNT(*) FROM table WHERE condition;
sql复制SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
sql复制SET GLOBAL innodb_buffer_pool_size=4294967296; -- 4GB
sql复制SET SESSION sql_log_bin=0;
sql复制DELETE FROM logs USE INDEX(idx_create_time)
WHERE create_time < '2022-01-01' LIMIT 1000;
某电商平台需要归档3年前的订单数据,表大小约800GB。我们采用的方案:
最终在业务几乎无感知的情况下完成了数据归档,将原表大小缩减到200GB,查询性能提升40%。