返回博客

企业微信回调日志多行 ≠ 客户重复操作,是平台的多级重试

某天凌晨,一条告警:同一用户的欢迎语发送失败。查回调日志表,同一个 welcome_code 下赫然有 3 行 raw_data 一模一样的记录,时间戳分别是 10:29:4710:29:5210: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)完全相同,包括 ExternalUserIDCreateTimewelcome_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 多写一行。

坑: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(...);
}
正解:业务表幂等 + 提前 ACK 双管齐下
  • callback_log 不需要加唯一键,多行是平台的正常事实,保留便于事后 audit 和 retry_rate 监控
  • 业务消费表(send_log、welcome_log 等)加 UNIQUE KEY (welcome_code) + INSERT IGNORE,彻底挡住重复消费
  • 主链路用 fastcgi_finish_request 提前 ACK,从源头让企业微信停止重试
注意: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 才是正道,不要把日志重复当客户问题去排查。