本文目录导读:

- 最基础:LIMIT + OFFSET 循环(适合低频、后台脚本)
- 高效方案:基于主键游标(推荐,千万级表可用)
- 生产环境:队列 + 守护脚本(适合Crontab/Supervisor)
- 极致性能:按物理分区(MySQL Partition)
- 时间-数量双重控制(超时保护)
- 常见注意事项
- 选择建议
在PHP项目中清理历史数据时,分批执行是避免内存溢出、数据库锁死或超时的关键策略,以下是几种常见且可靠的分批执行方案,按场景和复杂度排序:
最基础:LIMIT + OFFSET 循环(适合低频、后台脚本)
<?php
// 每批处理 1000 条
$batchSize = 1000;
$offset = 0;
do {
// 1. 查询需要删除的ID(避免直接删除时锁定大量行)
$stmt = $pdo->prepare("SELECT id FROM logs WHERE created_at < :cutoff ORDER BY id LIMIT :limit OFFSET :offset");
$stmt->execute([
':cutoff' => '2024-01-01 00:00:00',
':limit' => $batchSize,
':offset' => $offset
]);
$ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($ids)) {
break;
}
// 2. 一次性删除这批ID
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$pdo->prepare("DELETE FROM logs WHERE id IN ($placeholders)")->execute($ids);
$offset += $batchSize;
// 3. 可选:释放内存、记录进度
echo "已删除至偏移量: $offset\n";
gc_collect_cycles(); // 强制内存回收
usleep(50000); // 每批暂停50ms,降低数据库压力
} while (true);
缺点:大表时 OFFSET 越往后越慢(需扫描已跳过的行)。
高效方案:基于主键游标(推荐,千万级表可用)
<?php
$batchSize = 1000;
$lastId = 0; // 从最小ID开始
$cutoffDate = '2024-01-01 00:00:00';
do {
// 只查主键,且按ID升序取N条
$ids = $pdo->prepare("SELECT id FROM logs
WHERE id > :lastId AND created_at < :cutoff
ORDER BY id
LIMIT :limit");
$ids->execute([
':lastId' => $lastId,
':cutoff' => $cutoffDate,
':limit' => $batchSize
]);
$ids = $ids->fetchAll(PDO::FETCH_COLUMN);
if (empty($ids)) {
break;
}
// 删除这批数据
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$pdo->prepare("DELETE FROM logs WHERE id IN ($placeholders)")->execute($ids);
// 更新游标
$lastId = end($ids);
echo "已处理至ID: $lastId\n";
gc_collect_cycles();
} while (true);
优点:
- 避免 OFFSET,利用索引(
(id, created_at)复合索引更佳) - 每批之间无重复扫描,性能稳定
生产环境:队列 + 守护脚本(适合Crontab/Supervisor)
1 分批写入任务表
<?php
// create_cleanup_tasks.php (每小时或每日执行一次)
$batchSize = 5000;
$lastId = 0;
// 找出所有需要删除的ID范围,生成任务
do {
$ids = $pdo->prepare("SELECT id FROM logs
WHERE id > :lastId AND created_at < :cutoff
ORDER BY id LIMIT :limit");
// ... 同上获取 $ids
if (empty($ids)) break;
// 将批次ID写入任务表(或Redis队列)
$taskData = json_encode($ids);
$pdo->prepare("INSERT INTO cleanup_tasks (data, created_at) VALUES (?, NOW())")
->execute([$taskData]);
$lastId = end($ids);
} while (true);
2 消费者逐批执行
<?php
// worker.php (由 Supervisor 持续运行,或 Crontab 每分钟执行)
$pdo->beginTransaction();
$task = $pdo->prepare("SELECT id, data FROM cleanup_tasks WHERE processed = 0 LIMIT 1 FOR UPDATE");
$task->execute();
$row = $task->fetch();
if (!$row) {
exit("暂无待清理任务\n");
}
$ids = json_decode($row['data'], true);
try {
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$pdo->prepare("DELETE FROM logs WHERE id IN ($placeholders)")->execute($ids);
$pdo->prepare("UPDATE cleanup_tasks SET processed = 1 WHERE id = ?")->execute([$row['id']]);
$pdo->commit();
echo "已删除 " . count($ids) . " 条\n";
} catch (Exception $e) {
$pdo->rollBack();
// 记录错误,可选重试
}
极致性能:按物理分区(MySQL Partition)
如果表按时间分区(如按月),一个SQL即可清理整个分区:
ALTER TABLE logs DROP PARTITION p202401, p202402; -- 或用 TRUNCATE PARTITION ALTER TABLE logs TRUNCATE PARTITION p202401;
配合PHP:
<?php
// 获取要清除的分区列表
$partitions = $pdo->query("SELECT PARTITION_NAME FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_NAME = 'logs' AND PARTITION_DESCRIPTION < 20240101");
foreach ($partitions as $row) {
$pdo->exec("ALTER TABLE logs DROP PARTITION {$row['PARTITION_NAME']}");
}
优点:瞬间清理,不影响在线业务。
时间-数量双重控制(超时保护)
<?php
$maxExecutionTime = 58; // 预留2秒退出
$batchSize = 1000;
$startTime = time();
$lastId = 0;
do {
if (time() - $startTime >= $maxExecutionTime) {
echo "达到最大执行时间,暂停并记录断点...\n";
// 可选:将 $lastId 存入 Redis 或文件,下次从此继续
file_put_contents('/tmp/cleanup_cursor.txt', $lastId);
break;
}
// ... 游标方式获取 $ids 并删除 ...
} while (!empty($ids));
适用场景:Crontab 每隔几分钟执行,防止单次运行时间过长。
常见注意事项
| 问题 | 解决方案 |
|---|---|
| 大事务 | 每批单独提交(pdo->commit() / 不开启显式事务) |
| 锁冲突 | 使用 DELETE ... WHERE id IN (...) 而非 DELETE ... WHERE created_at < ...(避免全表锁) |
| 从库延迟 | 每批后 usleep(200000)(0.2秒)或检查主从延迟 SHOW SLAVE STATUS |
| 慢查询 | 确保 WHERE 条件有索引,如 (created_at, id) 复合索引 |
| 内存泄漏 | 每批后 unset($ids) + gc_collect_cycles() |
| 断点续传 | 将游标存入 Redis / MySQL 状态表 / 文件 |
选择建议
| 数据量 | 方案 |
|---|---|
| < 10万 | 方案1(简单直接) |
| 10万 ~ 500万 | 方案2(游标,最常用) |
| 500万 ~ 1亿 | 方案3(队列异步)或方案4(分区) |
| 实时业务 | 方案4(分区无锁)或方案2+LOW_PRIORITY |
推荐默认使用方案2(基于主键游标),它是性能、复杂度、安全性的最佳平衡点。