批量任务「撤回」按标题模糊删,误伤了整张共享主表
某次上线了一个站内信批量发送功能:运营在后台配好任务,系统异步跑批,把消息逐条写入一张共享的消息主表。上线没多久就来了新需求——「内容发错了,能不能撤回这一批?」
思路很顺:找到这一批写进去的行,把 del_flag 改成 1 就行了。但「找到这一批」这四个字,才是真正的坑。
现象
写出来的撤回 SQL 大概长这样:
UPDATE message_publish
SET del_flag = 1
WHERE msg_title = '【活动】双十一限时优惠'
AND create_at BETWEEN '2026-05-26 10:00:00' AND '2026-05-26 11:30:00';看起来无懈可击——标题对,时间对,条数也差不多。但上线一验:某些本不该被撤的消息也消失了。
查日志才发现:这张 message_publish 主表并不只由批量任务写,反馈回复通知、积分变动通知、评论提醒……六七条业务链路都往这里写。恰好那段时间有一条自动触发的反馈回复消息,标题字段和任务名有部分重叠,时间窗口也对上了——被连带软删了。
根因
共享主表 + 模糊条件 = 定时炸弹。
批量任务只是写入主表的其中一条来源。当撤回逻辑用业务字段(标题、时间、类型)来锁定行时,它实际上在做的是:「凡是看起来像这批任务写的,都删掉」——而「看起来像」天然覆盖不了所有来源,也天然会误伤无关行。
这个问题在系统早期往往不显眼,因为只有一条写入路径;等业务迭代、主表被复用之后,原本能用的模糊条件就变成了定时炸弹。
只要主表被多条业务路径写入,任何基于业务字段的模糊 WHERE 都可能误伤无关行。并且这种误伤在测试环境很难复现——因为测试库里往往只有一条写入来源。
正解
撤回的唯一可靠锚点,是批量任务在写入主表时存下来的那个主键。
第一步:写入成功后把主键回写到子目标行
每条任务都有一张「目标明细表」(每行对应一个接收对象)。当往主表写入成功后,立刻把生成的主键回写回来:
$publishId = MessageService::sendToUser($target['openid'], $task['title'], $task['content']);
Db::name('task_target')
->where('id', $target['id'])
->update([
'status' => STATUS_SENT,
'publish_id' => $publishId, // 主表主键,撤回时的唯一锚点
'send_at' => date('Y-m-d H:i:s'),
]);Schema 上加一列:
ALTER TABLE batch_task_target
ADD COLUMN publish_id VARCHAR(32) DEFAULT NULL
COMMENT '写入主表后的主键,撤回专用 SSoT',
ADD INDEX idx_task_publish (task_id, publish_id);第二步:撤回时严格按 ID 集合操作
// 收集本任务实际写入的主键集合
$publishIds = Db::name('batch_task_target')
->where('task_id', $taskId)
->where('status', STATUS_SENT)
->whereNotNull('publish_id')
->column('publish_id');
// 用事务包住:分块 UPDATE 主表 + 标记任务已撤回
$db->startTrans();
try {
$affected = 0;
foreach (array_chunk($publishIds, 500) as $chunk) {
$affected += (int)$db->name('message_publish')
->whereIn('id', $chunk) // ✅ 严格 ID 集合,不可能误伤
->where('del_flag', 0)
->update(['del_flag' => 1, 'update_at' => $now]);
}
// CAS recalled=0,防止双击重复撤回覆盖 recall_count
$updated = $db->name('batch_task')
->where('id', $taskId)
->where('recalled', 0)
->update(['recalled' => 1, 'recall_count' => $affected]);
if ((int)$updated === 0) {
$db->rollback();
throw new \Exception('已被其他操作撤回');
}
$db->commit();
} catch (\Exception $e) {
$db->rollback();
throw $e;
}三个必要保险:
| 保险 | 作用 |
|---|---|
| whereIn('id', $chunk) 严格 ID 集 | 物理上不可能误删其他来源的行 |
| startTrans() 包住全部步骤 | 分块 UPDATE 成功一半、任务状态却没更新的中间态不会存在 |
| WHERE recalled=0 CAS | 双击「撤回」不会把 recall_count 覆盖成 0 |
批量任务每写入一行主表,立刻把主键回存到子目标行。撤回时先 SELECT 出这批主键,再 WHERE id IN (...) 操作主表。无论主表以后被多少条路径共享,这个 WHERE 永远只命中你自己写的行。
排查验证
验证正确性只需要两条 SQL:
-- 1. 已撤回的行全在任务主键集合内
SELECT COUNT(*) FROM message_publish
WHERE del_flag = 1 AND id IN (<task-publish-ids>);
-- 应等于 recall_count
-- 2. 集合外没有任何误伤
SELECT COUNT(*) FROM message_publish
WHERE del_flag = 1 AND id NOT IN (<task-publish-ids>);
-- 必须为 0第二条是关键——如果返回非 0,说明还有其他行被误删,必须立刻排查。
一句话外卖
只要主表是共享的,撤回逻辑就只有一种安全写法:写入时存主键,删除时用主键集合——永远不用标题、时间、类型做模糊锚点。