企业微信回调日志多行 ≠ 客户重复操作,是平台的多级重试
某天凌晨,一条告警:同一用户的欢迎语发送失败。查回调日志表,同一个 welcome_code 下赫然有 3 行 raw_data 一模一样的记录,时间戳分别是 10:29:47、10:29:52、10:31:13。团队第一反应:「这个用户在疯狂点加好友?还是我们的消费代码有 bug 重复消费了?」
两个方向都排查了半小时,最终发现:两个方向都错了。
现象
SELECT id, welcome_code, callback_at
FROM callback_log
WHERE welcome_code = 'abc123'
ORDER BY callback_at;
-- 结果:
-- 1 abc123 2026-05-09 10:29:47
-- 2 abc123 2026-05-09 10:29:52 ← 5s 后
-- 3 abc123 2026-05-09 10:31:13 ← 约 86s 后3 行的 raw_data 字段(企业微信推过来的原始 XML)完全相同,包括 ExternalUserID、CreateTime、welcome_code。业务消费表(send_log)也跟着写了 3 行。
根因
这不是客户行为,也不是消费端 bug——是企业微信对自建应用回调的多级超时重试机制。
企业微信 POST 回调后等应用返回 echo 'success',ACK 窗口是 5 秒。超时未 ACK,企业微信立即重试,按如下时间表:
T+0s 企业微信 POST 回调 → 等应用 echo 'success'
T+5s 未收到 ACK → 立即重试 1 次(同 welcome_code)
T+10s 再未 ACK → 再重试 1 次
T+~90s 二级延迟约 84-90s 后重试 1 次
T+... 放弃(实测 3-4 次后停)
本案中应用主链路(handleExternalChange)同步执行了 88 秒:内部有 3 次冗余 COUNT 查询、标签同步、自动打标签等逻辑,全部串行。企业微信等 5s 没收到响应,开始重试;重试的每一次 POST 都让应用再跑一遍业务逻辑,每次都往 callback_log 多写一行。
同一 welcome_code 在回调日志里出现 N 行,时间间隔精确符合 ~5s / ~5s / ~84s 的节奏,100% 是企业微信重试,不是客户行为,也不是消费代码 bug。
把多行当「重复客户」去排查是方向性误判,会浪费大量排查时间,同时掩盖真实根因:应用主链路没在 5 秒内 ACK。
正解
1. 快速验证:看时间间隔
-- 时间间隔 ~5s / ~5s / ~84s = 企业微信多级重试 = 应用没及时 ACK
-- 时间间隔随机几小时 = 用户真的多次触发(不同业务事件,不同 welcome_code)
SELECT welcome_code, callback_at,
TIMESTAMPDIFF(SECOND,
LAG(callback_at) OVER (PARTITION BY welcome_code ORDER BY callback_at),
callback_at
) AS delta_s
FROM callback_log
WHERE welcome_code = '<xxx>'
ORDER BY callback_at;2. 给业务消费表加幂等唯一索引
callback_log 本身可以多行(保留重试审计),但业务表不能重复消费:
-- 在业务消费表加 UNIQUE KEY,防重试导致 N 次消费
ALTER TABLE welcome_send_log
ADD UNIQUE KEY uk_welcome_code (welcome_code);// PHP 端用 INSERT IGNORE,命中唯一键直接跳过
Db::query(
"INSERT IGNORE INTO welcome_send_log (welcome_code, ...) VALUES (?, ...)",
[$welcomeCode, ...]
);3. 让应用在 5s 内提前 ACK
public function handleExternalChange()
{
// 1. 先写 callback_log(审计用)
Db::name('callback_log')->insert([...]);
// 2. 立即 echo + fastcgi_finish_request 提前 ACK
if (function_exists('fastcgi_finish_request')) {
echo 'success';
fastcgi_finish_request(); // 已向企业微信返回 200,TCP 连接关闭
}
// 3. 后续业务逻辑在 ACK 之后慢慢跑
$this->syncContact(...);
$this->sendWelcomeMsg(...);
}callback_log不需要加唯一键,多行是平台的正常事实,保留便于事后 audit 和 retry_rate 监控- 业务消费表(send_log、welcome_log 等)加
UNIQUE KEY (welcome_code)+INSERT IGNORE,彻底挡住重复消费 - 主链路用
fastcgi_finish_request提前 ACK,从源头让企业微信停止重试
fastcgi_finish_request 能让企业微信 5s ACK 窗口不超时,但企业微信的 welcome_code 自带 20s 业务有效期(用于发欢迎语)。如果你的业务逻辑本身超过 20s,fastcgi_finish_request 救不了业务侧的 token 过期。根本解法是把耗时操作异步化,或把 send 动作提到链路最前面。
一句话外卖
企业微信 callback 日志同
welcome_code多行、间隔 ~5s/~84s,是平台 ACK 超时重试的铁证,不是客户行为——幂等 + 提前 ACK 才是正道,不要把日志重复当客户问题去排查。