找回密码
 立即注册
首页 业界区 业界 千万级大表如何删除数据?

千万级大表如何删除数据?

事确 5 天前
前言

今天我们来聊聊一个让很多DBA和开发者头疼的话题——千万级大表的数据删除。
有些小伙伴在工作中,一遇到大表数据删除就手足无措,要么直接DELETE导致数据库卡死,要么畏手畏脚不敢操作。
我见过太多因为大表删除操作不当导致的"血案":数据库长时间锁表、业务系统瘫痪、甚至主从同步延迟。
今天跟大家一起专门聊聊千万级大表数据删除的话题,希望对你会有所帮助。
一、为什么大表删除这么难?

在深入技术方案之前,我们先搞清楚为什么千万级大表的数据删除会如此困难。
有些小伙伴可能会想:"不就是个DELETE语句吗,有什么难的?"
其实这里面大有学问。
数据库删除操作的底层原理

为了更直观地理解数据库删除操作的工作原理,我画了一个删除操作的底层流程图:
1.webp

从这张图可以看出,一个简单的DELETE语句背后隐藏着这么多复杂的操作。
让我们详细分析每个环节的挑战:
1. 事务和锁的挑战
  1. -- 一个看似简单的删除操作
  2. DELETE FROM user_operation_log
  3. WHERE create_time < '2023-01-01';
  4. -- 实际上MySQL会这样处理:
  5. -- 1. 获取表的写锁
  6. -- 2. 逐行扫描10,000,000条记录
  7. -- 3. 对每条匹配的记录:
  8. --    - 写入undo log(用于回滚)
  9. --    - 写入redo log(用于恢复)
  10. --    - 更新所有相关索引
  11. --    - 标记记录为删除状态
  12. -- 4. 事务提交后才真正释放空间
复制代码
2. 资源消耗问题


  • 磁盘I/O:undo log、redo log、数据文件、索引文件的大量写入
  • CPU:索引维护、条件判断、事务管理
  • 内存:Buffer Pool管理、锁信息维护
  • 网络:主从同步数据量巨大
3. 业务影响风险


  • 锁等待超时:其他查询被阻塞
  • 主从延迟:从库同步跟不上
  • 磁盘空间:undo log暴增导致磁盘写满
  • 性能下降:数据库整体性能受影响
有些小伙伴可能会问:"我们用的是云数据库,这些问题还存在吗?"
我的经验是:云数据库只是降低了运维复杂度,但底层原理和限制依然存在
二、方案一:分批删除(最常用)

分批删除是最基础也是最常用的方案,核心思想是"化整为零",将大操作拆分成多个小操作。
实现原理

2.webp

具体实现

方法1:基于主键分批
  1. -- 存储过程实现分批删除
  2. DELIMITER $$
  3. CREATE PROCEDURE batch_delete_by_id()
  4. BEGIN
  5.     DECLARE done INT DEFAULT FALSE;
  6.     DECLARE batch_size INT DEFAULT 1000;
  7.     DECLARE max_id BIGINT;
  8.     DECLARE min_id BIGINT;
  9.     DECLARE current_id BIGINT DEFAULT 0;
  10.    
  11.     -- 获取需要删除的数据范围
  12.     SELECT MIN(id), MAX(id) INTO min_id, max_id
  13.     FROM user_operation_log
  14.     WHERE create_time < '2023-01-01';
  15.    
  16.     WHILE current_id < max_id DO
  17.         -- 每次删除一个批次
  18.         DELETE FROM user_operation_log
  19.         WHERE id BETWEEN current_id AND current_id + batch_size - 1
  20.         AND create_time < '2023-01-01';
  21.         
  22.         -- 提交事务,释放锁
  23.         COMMIT;
  24.         
  25.         -- 休眠一下,让数据库喘口气
  26.         DO SLEEP(0.1);
  27.         
  28.         -- 更新进度
  29.         SET current_id = current_id + batch_size;
  30.         
  31.         -- 记录日志(可选)
  32.         INSERT INTO delete_progress_log
  33.         VALUES (NOW(), current_id, batch_size);
  34.     END WHILE;
  35. END$$
  36. DELIMITER ;
复制代码
方法2:基于时间分批
  1. // Java代码实现基于时间的分批删除
  2. @Service
  3. @Slf4j
  4. public class BatchDeleteService {
  5.    
  6.     @Autowired
  7.     private JdbcTemplate jdbcTemplate;
  8.    
  9.     /**
  10.      * 基于时间范围的分批删除
  11.      */
  12.     public void batchDeleteByTime(String tableName, String timeColumn,
  13.                                   Date startTime, Date endTime,
  14.                                   int batchDays) {
  15.         
  16.         Calendar calendar = Calendar.getInstance();
  17.         calendar.setTime(startTime);
  18.         
  19.         int totalDeleted = 0;
  20.         long startMs = System.currentTimeMillis();
  21.         
  22.         while (calendar.getTime().before(endTime)) {
  23.             Date batchStart = calendar.getTime();
  24.             calendar.add(Calendar.DAY_OF_YEAR, batchDays);
  25.             Date batchEnd = calendar.getTime();
  26.             
  27.             // 确保不超过结束时间
  28.             if (batchEnd.after(endTime)) {
  29.                 batchEnd = endTime;
  30.             }
  31.             
  32.             String sql = String.format(
  33.                 "DELETE FROM %s WHERE %s BETWEEN ? AND ? LIMIT 1000",
  34.                 tableName, timeColumn
  35.             );
  36.             
  37.             int deleted = jdbcTemplate.update(sql, batchStart, batchEnd);
  38.             totalDeleted += deleted;
  39.             
  40.             log.info("批次删除完成: {}-{}, 删除{}条, 总计{}条",
  41.                     batchStart, batchEnd, deleted, totalDeleted);
  42.             
  43.             // 控制删除频率,避免对数据库造成过大压力
  44.             if (deleted > 0) {
  45.                 try {
  46.                     Thread.sleep(500); // 休眠500ms
  47.                 } catch (InterruptedException e) {
  48.                     Thread.currentThread().interrupt();
  49.                     break;
  50.                 }
  51.             } else {
  52.                 // 没有数据可删,跳到下一个时间段
  53.                 continue;
  54.             }
  55.             
  56.             // 每删除10000条记录一次进度
  57.             if (totalDeleted % 10000 == 0) {
  58.                 logProgress(totalDeleted, startMs);
  59.             }
  60.         }
  61.         
  62.         log.info("删除任务完成! 总计删除{}条记录, 耗时{}秒",
  63.                 totalDeleted, (System.currentTimeMillis() - startMs) / 1000);
  64.     }
  65.    
  66.     private void logProgress(int totalDeleted, long startMs) {
  67.         long costMs = System.currentTimeMillis() - startMs;
  68.         double recordsPerSecond = totalDeleted * 1000.0 / costMs;
  69.         
  70.         log.info("删除进度: {}条, 速率: {}/秒, 耗时: {}秒",
  71.                 totalDeleted, String.format("%.2f", recordsPerSecond), costMs / 1000);
  72.     }
  73. }
复制代码
方法3:使用LIMIT分批删除
  1. -- 简单的LIMIT分批删除
  2. DELIMITER $$
  3. CREATE PROCEDURE batch_delete_with_limit()
  4. BEGIN
  5.     DECLARE done INT DEFAULT 0;
  6.     DECLARE batch_size INT DEFAULT 1000;
  7.     DECLARE total_deleted INT DEFAULT 0;
  8.    
  9.     WHILE done = 0 DO
  10.         -- 每次删除1000条
  11.         DELETE FROM user_operation_log
  12.         WHERE create_time < '2023-01-01'
  13.         LIMIT batch_size;
  14.         
  15.         -- 检查是否还有数据
  16.         SET done = ROW_COUNT() = 0;
  17.         SET total_deleted = total_deleted + ROW_COUNT();
  18.         
  19.         -- 提交释放锁
  20.         COMMIT;
  21.         
  22.         -- 休眠控制频率
  23.         DO SLEEP(0.1);
  24.         
  25.         -- 每删除10000条输出日志
  26.         IF total_deleted % 10000 = 0 THEN
  27.             SELECT CONCAT('已删除: ', total_deleted, ' 条记录') AS progress;
  28.         END IF;
  29.     END WHILE;
  30.    
  31.     SELECT CONCAT('删除完成! 总计: ', total_deleted, ' 条记录') AS result;
  32. END$$
  33. DELIMITER ;
复制代码
分批删除的最佳实践


  • 批次大小选择

    • 小表:1000-5000条/批次
    • 大表:100-1000条/批次
    • 需要根据实际情况调整

  • 休眠时间控制

    • 业务高峰期:休眠1-2秒
    • 业务低峰期:休眠100-500毫秒
    • 夜间维护:可不休眠或短暂休眠

  • 监控和调整

    • 监控数据库负载
    • 观察主从同步延迟
    • 根据实际情况动态调整参数

三、方案二:创建新表+重命名

当需要删除表中大部分数据时,创建新表然后重命名的方式往往更高效。
实现原理

3.webp

具体实现
  1. -- 步骤1: 创建新表(结构同原表)
  2. CREATE TABLE user_operation_log_new LIKE user_operation_log;
  3. -- 步骤2: 导入需要保留的数据
  4. INSERT INTO user_operation_log_new
  5. SELECT * FROM user_operation_log
  6. WHERE create_time >= '2023-01-01';
  7. -- 步骤3: 创建索引(在数据导入后创建,效率更高)
  8. ALTER TABLE user_operation_log_new ADD INDEX idx_create_time(create_time);
  9. ALTER TABLE user_operation_log_new ADD INDEX idx_user_id(user_id);
  10. -- 步骤4: 数据验证
  11. SELECT
  12.     (SELECT COUNT(*) FROM user_operation_log_new) as new_count,
  13.     (SELECT COUNT(*) FROM user_operation_log WHERE create_time >= '2023-01-01') as expected_count;
  14. -- 步骤5: 原子切换(需要很短的表锁)
  15. RENAME TABLE
  16.     user_operation_log TO user_operation_log_old,
  17.     user_operation_log_new TO user_operation_log;
  18. -- 步骤6: 删除旧表(可选立即删除或延后删除)
  19. DROP TABLE user_operation_log_old;
复制代码
Java代码辅助实现
  1. @Service  
  2. @Slf4j
  3. public class TableRebuildService {
  4.    
  5.     @Autowired
  6.     private JdbcTemplate jdbcTemplate;
  7.    
  8.     /**
  9.      * 重建表方式删除数据
  10.      */
  11.     public void rebuildTableForDeletion(String sourceTable, String condition) {
  12.         String newTable = sourceTable + "_new";
  13.         String oldTable = sourceTable + "_old";
  14.         
  15.         try {
  16.             // 1. 创建新表
  17.             log.info("开始创建新表: {}", newTable);
  18.             jdbcTemplate.execute("CREATE TABLE " + newTable + " LIKE " + sourceTable);
  19.             
  20.             // 2. 导入需要保留的数据
  21.             log.info("开始导入保留数据");
  22.             String insertSql = String.format(
  23.                 "INSERT INTO %s SELECT * FROM %s WHERE %s",
  24.                 newTable, sourceTable, condition
  25.             );
  26.             int keptCount = jdbcTemplate.update(insertSql);
  27.             log.info("成功导入{}条保留数据", keptCount);
  28.             
  29.             // 3. 创建索引(可选,在导入后创建索引效率更高)
  30.             log.info("开始创建索引");
  31.             createIndexes(newTable);
  32.             
  33.             // 4. 数据验证
  34.             log.info("开始数据验证");
  35.             if (!validateData(sourceTable, newTable, condition)) {
  36.                 throw new RuntimeException("数据验证失败");
  37.             }
  38.             
  39.             // 5. 原子切换
  40.             log.info("开始表切换");
  41.             switchTables(sourceTable, newTable, oldTable);
  42.             
  43.             // 6. 删除旧表(可选立即或延后)
  44.             log.info("开始删除旧表");
  45.             dropTableSafely(oldTable);
  46.             
  47.             log.info("表重建删除完成!");
  48.             
  49.         } catch (Exception e) {
  50.             log.error("表重建过程发生异常", e);
  51.             // 清理临时表
  52.             cleanupTempTable(newTable);
  53.             throw e;
  54.         }
  55.     }
  56.    
  57.     private void createIndexes(String tableName) {
  58.         // 根据业务需要创建索引
  59.         String[] indexes = {
  60.             "CREATE INDEX idx_create_time ON " + tableName + "(create_time)",
  61.             "CREATE INDEX idx_user_id ON " + tableName + "(user_id)"
  62.         };
  63.         
  64.         for (String sql : indexes) {
  65.             jdbcTemplate.execute(sql);
  66.         }
  67.     }
  68.    
  69.     private boolean validateData(String sourceTable, String newTable, String condition) {
  70.         // 验证新表数据量是否正确
  71.         Integer newCount = jdbcTemplate.queryForObject(
  72.             "SELECT COUNT(*) FROM " + newTable, Integer.class);
  73.         
  74.         Integer expectedCount = jdbcTemplate.queryForObject(
  75.             "SELECT COUNT(*) FROM " + sourceTable + " WHERE " + condition, Integer.class);
  76.         
  77.         return newCount.equals(expectedCount);
  78.     }
  79.    
  80.     private void switchTables(String sourceTable, String newTable, String oldTable) {
  81.         // 原子性的表重命名操作
  82.         String sql = String.format(
  83.             "RENAME TABLE %s TO %s, %s TO %s",
  84.             sourceTable, oldTable, newTable, sourceTable
  85.         );
  86.         jdbcTemplate.execute(sql);
  87.     }
  88.    
  89.     private void dropTableSafely(String tableName) {
  90.         try {
  91.             jdbcTemplate.execute("DROP TABLE " + tableName);
  92.         } catch (Exception e) {
  93.             log.warn("删除表失败: {}, 需要手动清理", tableName, e);
  94.         }
  95.     }
  96.    
  97.     private void cleanupTempTable(String tableName) {
  98.         try {
  99.             jdbcTemplate.execute("DROP TABLE IF EXISTS " + tableName);
  100.         } catch (Exception e) {
  101.             log.warn("清理临时表失败: {}", tableName, e);
  102.         }
  103.     }
  104. }
复制代码
适用场景


  • 需要删除表中超过50%的数据
  • 业务允许短暂的写停顿(重命名时需要)
  • 有足够的磁盘空间存储新旧两个表
四、方案三:分区表删除

如果表已经做了分区,或者可以改造为分区表,那么删除数据就会变得非常简单。
实现原理

4.webp

具体实现

方法1:使用现有分区表
  1. -- 查看表的分区情况
  2. SELECT table_name, partition_name, table_rows
  3. FROM information_schema.partitions
  4. WHERE table_name = 'user_operation_log';
  5. -- 直接删除整个分区(秒级完成)
  6. ALTER TABLE user_operation_log DROP PARTITION p202201, p202202;
  7. -- 定期删除过期分区的存储过程
  8. DELIMITER $$
  9. CREATE PROCEDURE auto_drop_expired_partitions()
  10. BEGIN
  11.     DECLARE expired_partition VARCHAR(64);
  12.     DECLARE done INT DEFAULT FALSE;
  13.    
  14.     -- 查找需要删除的分区(保留最近12个月)
  15.     DECLARE cur CURSOR FOR
  16.     SELECT partition_name
  17.     FROM information_schema.partitions
  18.     WHERE table_name = 'user_operation_log'
  19.     AND partition_name LIKE 'p%'
  20.     AND STR_TO_DATE(REPLACE(partition_name, 'p', ''), '%Y%m') < DATE_SUB(NOW(), INTERVAL 12 MONTH);
  21.    
  22.     DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
  23.    
  24.     OPEN cur;
  25.    
  26.     read_loop: LOOP
  27.         FETCH cur INTO expired_partition;
  28.         IF done THEN
  29.             LEAVE read_loop;
  30.         END IF;
  31.         
  32.         -- 删除过期分区
  33.         SET @sql = CONCAT('ALTER TABLE user_operation_log DROP PARTITION ', expired_partition);
  34.         PREPARE stmt FROM @sql;
  35.         EXECUTE stmt;
  36.         DEALLOCATE PREPARE stmt;
  37.         
  38.         -- 记录日志
  39.         INSERT INTO partition_clean_log
  40.         VALUES (NOW(), expired_partition, 'DROPPED');
  41.     END LOOP;
  42.    
  43.     CLOSE cur;
  44. END$$
  45. DELIMITER ;
复制代码
方法2:改造普通表为分区表
  1. -- 将普通表改造成分区表
  2. -- 步骤1: 创建分区表
  3. CREATE TABLE user_operation_log_partitioned (
  4.     id BIGINT AUTO_INCREMENT,
  5.     user_id BIGINT,
  6.     operation VARCHAR(100),
  7.     create_time DATETIME,
  8.     PRIMARY KEY (id, create_time)  -- 分区键必须包含在主键中
  9. ) PARTITION BY RANGE (YEAR(create_time)*100 + MONTH(create_time)) (
  10.     PARTITION p202201 VALUES LESS THAN (202202),
  11.     PARTITION p202202 VALUES LESS THAN (202203),
  12.     PARTITION p202203 VALUES LESS THAN (202204),
  13.     PARTITION p202204 VALUES LESS THAN (202205),
  14.     PARTITION pfuture VALUES LESS THAN MAXVALUE
  15. );
  16. -- 步骤2: 导入数据
  17. INSERT INTO user_operation_log_partitioned
  18. SELECT * FROM user_operation_log;
  19. -- 步骤3: 切换表
  20. RENAME TABLE
  21.     user_operation_log TO user_operation_log_old,
  22.     user_operation_log_partitioned TO user_operation_log;
  23. -- 步骤4: 定期维护:添加新分区
  24. ALTER TABLE user_operation_log REORGANIZE PARTITION pfuture INTO (
  25.     PARTITION p202205 VALUES LESS THAN (202206),
  26.     PARTITION p202206 VALUES LESS THAN (202207),
  27.     PARTITION pfuture VALUES LESS THAN MAXVALUE
  28. );
复制代码
Java代码实现分区管理
  1. @Service
  2. @Slf4j
  3. public class PartitionManagerService {
  4.    
  5.     @Autowired
  6.     private JdbcTemplate jdbcTemplate;
  7.    
  8.     /**
  9.      * 自动管理分区
  10.      */
  11.     @Scheduled(cron = "0 0 2 * * ?")  // 每天凌晨2点执行
  12.     public void autoManagePartitions() {
  13.         log.info("开始分区维护任务");
  14.         
  15.         try {
  16.             // 1. 删除过期分区(保留最近12个月)
  17.             dropExpiredPartitions();
  18.             
  19.             // 2. 创建未来分区
  20.             createFuturePartitions();
  21.             
  22.             log.info("分区维护任务完成");
  23.             
  24.         } catch (Exception e) {
  25.             log.error("分区维护任务失败", e);
  26.         }
  27.     }
  28.    
  29.     private void dropExpiredPartitions() {
  30.         String sql = "SELECT partition_name " +
  31.                     "FROM information_schema.partitions " +
  32.                     "WHERE table_name = 'user_operation_log' " +
  33.                     "AND partition_name LIKE 'p%' " +
  34.                     "AND STR_TO_DATE(REPLACE(partition_name, 'p', ''), '%Y%m') < DATE_SUB(NOW(), INTERVAL 12 MONTH)";
  35.         
  36.         List<String> expiredPartitions = jdbcTemplate.queryForList(sql, String.class);
  37.         
  38.         for (String partition : expiredPartitions) {
  39.             try {
  40.                 jdbcTemplate.execute("ALTER TABLE user_operation_log DROP PARTITION " + partition);
  41.                 log.info("成功删除分区: {}", partition);
  42.                
  43.                 // 记录操作日志
  44.                 logPartitionOperation("DROP", partition, "SUCCESS");
  45.                
  46.             } catch (Exception e) {
  47.                 log.error("删除分区失败: {}", partition, e);
  48.                 logPartitionOperation("DROP", partition, "FAILED: " + e.getMessage());
  49.             }
  50.         }
  51.     }
  52.    
  53.     private void createFuturePartitions() {
  54.         // 创建未来3个月的分区
  55.         for (int i = 1; i <= 3; i++) {
  56.             LocalDate futureDate = LocalDate.now().plusMonths(i);
  57.             String partitionName = "p" + futureDate.format(DateTimeFormatter.ofPattern("yyyyMM"));
  58.             int partitionValue = futureDate.getYear() * 100 + futureDate.getMonthValue();
  59.             int nextPartitionValue = partitionValue + 1;
  60.             
  61.             try {
  62.                 String sql = String.format(
  63.                     "ALTER TABLE user_operation_log REORGANIZE PARTITION pfuture INTO (" +
  64.                     "PARTITION %s VALUES LESS THAN (%d), " +
  65.                     "PARTITION pfuture VALUES LESS THAN MAXVALUE)",
  66.                     partitionName, nextPartitionValue
  67.                 );
  68.                
  69.                 jdbcTemplate.execute(sql);
  70.                 log.info("成功创建分区: {}", partitionName);
  71.                 logPartitionOperation("CREATE", partitionName, "SUCCESS");
  72.                
  73.             } catch (Exception e) {
  74.                 log.warn("创建分区失败(可能已存在): {}", partitionName, e);
  75.             }
  76.         }
  77.     }
  78.    
  79.     private void logPartitionOperation(String operation, String partition, String status) {
  80.         jdbcTemplate.update(
  81.             "INSERT INTO partition_operation_log(operation, partition_name, status, create_time) VALUES (?, ?, ?, NOW())",
  82.             operation, partition, status
  83.         );
  84.     }
  85. }
复制代码
七、方案对比与选择指南

为了帮助大家选择合适的方案,我整理了详细的对比表:
方案对比矩阵

方案适用场景优点缺点风险等级分批删除小批量删除,
删除比例50%,
可接受短暂停写执行速度快,
整理表碎片需要停写,
需要额外空间高分区删除表已分区或可分区秒级完成,
无性能影响需要前期规划,
改造成本低在线同步要求零停机,
重要业务表业务无感知,
安全可靠实现复杂,
周期较长中专业工具复杂场景,
超大表操作功能强大,
自动优化学习成本,
依赖外部工具中选择决策流程图

5.webp

实战建议


  • 测试环境验证:任何删除方案都要先在测试环境验证
  • 备份优先:删除前一定要备份数据
  • 业务低峰期:选择业务低峰期执行删除操作
  • 监控告警:实时监控数据库状态,设置告警阈值
  • 回滚预案:准备完善的回滚方案
更多项目实战:susan.net.cn/project
总结

经过上面的详细分析,我们来总结一下千万级大表数据删除的核心要点。
核心原则


  • 安全第一:任何删除操作都要确保数据安全
  • 影响最小:尽量减少对业务的影响
  • 效率优先:选择最适合的高效方案
  • 可监控:整个过程要可监控、可控制
技术选型口诀

根据多年的实战经验,我总结了一个简单的选型口诀:
看分区,判比例,定方案

  • 有分区:直接删除分区最快
  • 删的少:分批删除最稳妥
  • 删的多:重建表最高效
  • 不能停:在线同步最安全
最后的建议

大表数据删除是一个需要谨慎对待的操作,我建议大家:

  • 预防优于治疗:通过数据生命周期管理,定期清理数据
  • 架构要合理:在设计阶段就考虑数据清理策略
  • 工具要熟练:掌握各种删除工具的使用方法
  • 经验要积累:每次操作后都要总结经验教训
记住:没有最好的方案,只有最适合的方案

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

昨天 02:13

举报

您需要登录后才可以回帖 登录 | 立即注册