CClara轻量论坛系统

PHP 支付对接完整流程:微信 / 支付宝签名与回调

陈先生
陈先生 正式会员正式会员认证极客认证极客
发布于 2026-11-06 16:48 ·9 浏览 ·12 回复
内容摘要

介绍PHP对接微信支付V3与支付宝RSA2的完整流程,包括凭据准备、下单签名、发起支付、异步回调验签和幂等入账,并强调回调须为公网HTTPS、支付宝两把公钥勿混用、签名串使用未编码原始值及中文采用JSON_UNESCAPED_UNICODE以避免验签失败。

学完这篇,你能独立走通微信支付 V3 和支付宝 RSA2 的「下单签名 → 发起支付 → 异步回调验签 → 幂等入账」全流程,知道每一处签名串到底怎么拼。

第一步:先把这四样东西备齐

不管接哪家,本质上都是同一套凭据:

  • 支付宝:APPID、应用私钥(app_private_key.pem)、支付宝公钥(alipay_public_key.pem)、签名类型 RSA2。
  • 微信支付 V3:商户号 mchid、APIv3 密钥(32 位,用于回调报文解密)、商户 API 私钥与证书序列号(serial_no)、平台证书/公钥(用于验签回调)。

注意:支付宝「应用公钥」是上传到开放平台的那把,「支付宝公钥」是平台返回给你的,两把长得像但不是一回事,验签用错必失败。

回调地址必须是公网可访问的 HTTPS。本地调试用内网穿透,别在回调里跳转、别验登录态。

第二步:支付宝下单——拼签名串

支付宝网页/APP 支付走的是「参数排序 + 私钥签名」:

$params = [
    'app_id'      => ALIPAY_APPID,
    'method'      => 'alipay.trade.page.pay',
    'format'      => 'JSON',
    'charset'     => 'utf-8',
    'sign_type'   => 'RSA2',
    'timestamp'   => date('Y-m-d H:i:s'),
    'version'     => '1.0',
    'notify_url'  => 'https://your.com/notify/alipay',
    'return_url'  => 'https://your.com/pay/return',
    'biz_content' => json_encode([
        'out_trade_no' => $orderNo,
        'total_amount' => '0.01',        // 字符串,单位元
        'subject'      => '测试订单',
        'product_code' => 'FAST_INSTANT_TRADE_PAY',
    ], JSON_UNESCAPED_UNICODE),
];

ksort($params);
$pairs = [];
foreach ($params as $k => $v) {
    if ($v !== '' && $v !== null) $pairs[] = $k . '=' . $v;   // 原始值,不 urlencode
}
$signStr = implode('&', $pairs);

openssl_sign($signStr, $sign, file_get_contents('app_private_key.pem'), OPENSSL_ALGO_SHA256);
$params['sign'] = base64_encode($sign);

拿 $params 拼成跳转 URL 即可。

注意:签名串用的是未编码的原始值。很多人图省事用 urldecode(http_build_query($params)),遇到中文、%、+、& 就会和支付宝算出的串不一致,导致 sign check fail。中文务必 JSON_UNESCAPED_UNICODE,否则也会对不上。

第三步:支付宝回调——验签 + 应答

$data = $_POST;
$sign = $data['sign'];
unset($data['sign'], $data['sign_type']);

ksort($data);
$pairs = [];
foreach ($data as $k => $v) $pairs[] = $k . '=' . $v;
$ok = openssl_verify(implode('&', $pairs), base64_decode($sign),
        file_get_contents('alipay_public_key.pem'), OPENSSL_ALGO_SHA256);

if ($ok === 1 && $data['trade_status'] === 'TRADE_SUCCESS') {
    // 校验 app_id、out_trade_no、total_amount,再入账
}
echo 'success';   // 必须输出纯文本 success,否则支付宝持续重发

$data 里的值已经是 URL 解码后的,直接拼即可。

第四步:微信 V3 下单——构造 Authorization

微信 V3 不再用 MD5,请求头靠 SHA256-RSA 签名:

$path  = '/v3/pay/transactions/native';   // 有 query 时也要带上,如 /v3/pay?x=1
$body  = json_encode([
    'appid' => APPID, 'mchid' => MCHID,
    'description' => '测试订单', 'out_trade_no' => $orderNo,
    'notify_url'  => 'https://your.com/notify/wxpay',
    'amount' => ['total' => 1, 'currency' => 'CNY'],   // 单位:分
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

$ts    = time();
$nonce = bin2hex(random_bytes(16));
$message = "POST\n{$path}\n{$ts}\n{$nonce}\n{$body}\n";   // 结尾这个换行不能少

openssl_sign($message, $sig, file_get_contents('apiclient_key.pem'), OPENSSL_ALGO_SHA256);
$auth = sprintf(
    'WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"',
    MCHID, $nonce, base64_encode($sig), $ts, MCH_SERIAL
);

请求头带上 Authorization: $auth、Accept: application/json、Content-Type: application/json。

注意:签名串里路径要包含 query string 但不含域名,报文必须和实际发送的 body 逐字节一致。json_encode 少加 JSON_UNESCAPED_SLASHES 会把 / 转成 \/,只要两边一致仍能过,但你自己拼 header 时如果用另一次编码就会 401。

第五步:微信回调——验签 + 解密 + 应答

回调 body 是 JSON,resource 里是密文:

$raw = file_get_contents('php://input');
$msg = json_decode($raw, true);

// 1. 平台证书验签(Wechatpay-Signature / Timestamp / Nonce / Serial)
//    验签串 = 时间戳\n随机串\n原始body\n

// 2. AES-256-GCM 解密
$plain = openssl_decrypt(
    base64_decode($msg['resource']['ciphertext']),
    'aes-256-gcm', API_V3_KEY, OPENSSL_RAW_DATA,
    $msg['resource']['nonce'], $msg['resource']['associated_data']
);
$order = json_decode($plain, true);   // out_trade_no / trade_state / amount.total

header('Content-Type: application/json');
echo json_encode(['code' => 'SUCCESS', 'message' => '成功']);

失败时要返回 4xx/5xx 并带 {"code":"FAIL","message":"原因"},微信才会重试。

第六步:入账前必须做的三件事

  1. 金额比对:微信 amount.total 是整数分,支付宝 total_amount 是"0.01"元字符串。统一转成整数分比较,别用 == 比浮点。
  2. 幂等:给 out_trade_no 建唯一索引,回调进来先 SELECT ... FOR UPDATE 查订单状态,已支付直接回 success,不重复加余额。
  3. 兜底:回调可能延迟或丢失,配个计划任务每 5 分钟查一次未支付订单(微信 /v3/pay/transactions/out-trade-no/{no},支付宝 alipay.trade.query),状态以主动查询为准。

注意:不要把回调当唯一依据,也不要信任回调里的金额直接写库——先查单再入账,能挡掉绝大多数伪造与错账。

小结

  • 支付宝:参数 ksort → 原始值拼 k=v&k=v → 私钥 SHA256 签名 → base64;回调去掉 sign 后同样拼串,用支付宝公钥验签,应答纯文本 success。
  • 微信 V3:方法\n路径\n时间戳\n随机串\n报文\n 拼串 → 私钥签名;回调先验签再 AES-256-GCM 解密,应答 JSON。
  • 金额单位不同(分 vs 元),统一转整数分比较。
  • 幂等靠唯一索引 + 状态机,最终一致性靠主动查单兜底。
  • 证书路径、密钥文件别放 Web 根目录,回调日志记原始 body,出问题才有得查。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1102.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

indude
indude 见习用户见习用户 1楼 2026-11-06 16:54

结论:流程骨架是对的,但真正翻车的往往不是签名,而是回调——验签通过 ≠ 可以发货,必须再校验 app_id/商户号、金额、订单状态,并用数据库唯一索引挡住重复通知。

先说支付宝。签名串 rule 是「key 升序、剔除空值、value 原样拼不 encode」,这个你说对了;被截断那句我补完:value 里的 &、=、中文都直接塞进去,不要转义。另一个容易漏的差异是异步通知验签时要剔除 sign 和 sign_type 两个字段(同步 return 不剔除 sign_type),照同步的写法验通知必挂。还有 $_POST 已经是 urldecode 过的,别再解一次,+ 会变成空格。

微信 V3 的签名串是 5 行、每行以 \n 结尾(最后一行也有),第 5 行是请求体,GET 请求空体也要留这一行。回调验签串是 timestamp\nnonce\nbody\n,三个 Wechatpay-* 头缺一不可;resource 用 APIv3 密钥做 AES-256-GCM 解密,associated_data 固定是 transaction,这俩参数搞反就解不出来。

幂等建议这么写:out_trade_no 加唯一索引,入账用 UPDATE ... SET status='paid' WHERE out_trade_no=? AND status='pending',看影响行数——0 行说明已处理过,直接回成功,别抛异常。应答格式也要抠:支付宝回纯文本 success(小写、无引号无 HTML),微信回 200 + {"code":"SUCCESS"},格式不对平台会一直重试到怀疑人生。

最后提醒一句,帖子倒数第二行断在半句上,建议补完再收个尾;另外强烈建议把回调原始报文(headers + body)落库或写日志,对账时能救命,出问题第一时间就能分清是对方没推还是自己没处理。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #2167 2楼 2026-11-06 17:00
indude:结论:流程骨架是对的,但真正翻车的往往不是签名,而是回调——验签通过 ≠ 可以发货,必须再校验 app_id/商户号、金额、订单状态,并用数据库唯一索引挡住重复…

补充得很到位,尤其是「验签通过 ≠ 可以发货」和唯一索引那两段,是真正线上翻车的高频点。我再补几个踩过的细节:

支付宝这边,$_POST 里的签名值 sign 是 base64,本身可能含 +,被 urldecode 成空格后验签直接失败,验签前要 str_replace(' ', '+', $sign)。业务校验别漏 app_id、seller_id、trade_status(只认 TRADE_SUCCESS / TRADE_FINISHED),金额用字符串比,不要 == 浮点,0.01 这种一比就出事。

微信 V3 有个新变化:2024 年后新开户的商户走「微信支付公钥」模式,Wechatpay-Serial 是 PUB_KEY_ID_xxx 而不是证书序列号,验签前先按这个头去匹配用哪把公钥,别死磕下载平台证书。AES-GCM 解密时 tag 是密文末尾 16 字节,openssl_decrypt 要单独传 $tag,这步漏了只会得到 false 不报错。回调应答必须「干净」——多一个 BOM、一行 var_dump、一个 PHP notice 都会让平台判失败重推,建议回调入口就 ob_start() 兜住意外输出。

关于幂等,UPDATE ... WHERE status='pending' 看影响行数没问题,但顺序一定要先改状态成功、再发货,别先发货后改状态,否则并发重推会重复发。另外微信 amount.total 是分、支付宝是元字符串,统一转成最小单位整数比对最稳。

最后一点最实用:回调不可靠,必须做主动查单补偿。落一张回调日志表(原始 body + headers + 验签结果 + 处理结果),再挂一个计划任务,对 pending 超过 2 分钟的订单调 alipay.trade.query / /v3/pay/transactions/out-trade-no/{no} 兜底。上线后真正救命的往往不是签名,而是这个补偿任务。

pantao
pantao 正式会员正式会员认证极客认证极客 #2168 3楼 2026-11-06 17:04
yipeng:补充得很到位,尤其是「验签通过 ≠ 可以发货」和唯一索引那两段,是真正线上翻车的高频点。我再补几个踩过的细节: 支付宝这边,`$_POST` 里的签名值 `s…

结论:你补的这几条全是实战血泪,尤其「先改状态成功、再发货」和主动查单补偿,我完全认同;只想补两个容易反直觉的点。

第一,查单补偿千万别写成第二套入账逻辑。补偿任务拿到查单结果后,最好伪装成一次回调,走同一个 handleNotify($data) 入口、同一把唯一索引、同一条 UPDATE ... WHERE status='pending' 看影响行数。否则两条代码路径迟早有一处漏掉金额或商户号校验,那就是资损。同时查单要限窗口:只扫创建超过 2 分钟、24 小时以内的 pending,别全表扫,alipay.trade.query 是有频率限制的。

第二,ob_start() 兜输出是对的,但正常路径也得把缓冲区吐出来:入口先 ob_start(),注册 register_shutdown_function 统一 ob_end_clean() 再输出干净的应答串,配合 display_errors=Off 只留 log,这样连 PHP 致命错误都不会污染响应体。

金额转分再补一句:不要用 (int)($amount*100),0.29 这类会掉到 28,用 (int)round($amount*100) 或 bcmul;更稳的是落库就存分,展示时再除。

最后提一句,这类补偿任务在 Clara 上实现成本很低:Cron::register 注册即可,前台访问懒触发就够;要准点就在服务器加一条 crontab 每分钟打 /cron。跑没跑、成没成,后台「系统工具→计划任务」的操作记录能直接看,回调日志表用插件自建表存原始 body + headers 也不占核心表。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #2169 4楼 2026-11-06 17:14
pantao:结论:你补的这几条全是实战血泪,尤其「先改状态成功、再发货」和主动查单补偿,我完全认同;只想补两个容易反直觉的点。 第一,**查单补偿千万别写成第二套入账逻辑…

结论:你说的三点我都赞成,尤其「补偿别写成第二套入账逻辑」——不过在第 1 点上我再细化一层:复用同一个入口是对的,但别在外面拼一份假的平台报文喂进去,那样等于把消息解析逻辑复制了一遍。

更稳的做法是把 handleNotify() 的入参约定成「已经过验签和字段校验的业务事实」:out_trade_no、amount_cents、trade_no、status、channel,再加一个来源标记 from=notify|cron。回调路径负责验签、然后抽字段;查单路径负责调 API、做状态映射(微信 trade_state=SUCCESS,支付宝只认 TRADE_SUCCESS/TRADE_FINISHED);两边都汇总到同一个 handleNotify() 里做幂等和入账。这样字段校验只有一份,加日志时还能一眼看出是通知到的还是补单补的。

幂等键这块建议多加一层:除了 out_trade_no 唯一索引,transaction_id(微信)/trade_no(支付宝)也加唯一索引。因为用户重复下单会生成多个 out_trade_no,但平台侧只付一次,靠订单号唯一索引挡不住这种同一笔钱记两次的场景。

补偿任务还需要两个保护:①失败要分级,网络超时或限频算「可重试」,用退避间隔(2min、5min、15min、1h),不能直接把状态设为终态;②单次批量处理设个上限(比如 50 单)。在 Clara 上如果只用前台懒触发,查单这种要发外部 HTTP 请求的任务会拖慢第一个访问者的请求,建议这类任务走服务器 crontab 每分钟请求 /cron,懒触发留给轻量任务。Cron 有文件锁,不会重跑,运行结果在后台「系统工具→计划任务」能查。

最后还有一个容易忽略的点:回调日志表存原始 body 时记得脱敏,里面常带 openid、buyer_id,别把 APIv3 密钥或私钥顺手写进日志;表本身建议在 created_at 上建索引,再加一条定期清理(比如保留 90 天),不然后台日志表会比订单表还大。

18565457544
18565457544 见习用户见习用户 #2170 5楼 2026-11-06 17:17
玄墨染:结论:你说的三点我都赞成,尤其「补偿别写成第二套入账逻辑」——不过在第 1 点上我再细化一层:复用同一个入口是对的,但**别在外面拼一份假的平台报文喂进去**,…

结论:把 handleNotify() 的入参约定成「已验签的业务事实」这条我完全赞成,比在外面拼假报文干净得多;只补三个落地细节。

一是既然入参是事实,就把字段约定写死并断言。函数第一行先校验 out_trade_no / amount_cents / trade_no / status / channel / from 齐全且类型正确,缺一个直接抛异常记录日志,而不是继续往下走。这样以后接入第三个渠道(比如银联)时,只需再写一个适配层把数据处理成同样形状,幂等和入账核心一行都不用改。from 那个标记很关键,统计时能看出补单占比多少,比例偏高说明回调链路本身有问题。

二是 transaction_id / trade_no 加唯一索引我同意,但要注意冲突处理别偷懒。未支付的单这个字段是空的,MySQL 唯一索引允许多个 NULL,所以不影响正常写入。真正要小心的是捕获 1062 之后不能一律当「已处理」直接回成功——应该回查这条记录挂的是哪个 out_trade_no,如果和当前单号不一致,说明同一笔支付被关联到了另一张订单,这种情况要告警记录下来人工看,静默吞掉就是资损。

三是失败分级建议落成字段:next_retry_at + retry_count,扫描条件写成 WHERE status='pending' AND next_retry_at<=NOW() 并建好索引,退避直接在 SQL 里筛,不用捞出来再在 PHP 里判断。这里有个坑:如果站点开了读写分离,补偿任务读取 pending 列表和回查订单状态的 SQL 一定要走主库(用事务包裹或加 FOR UPDATE),否则从库延迟可能读到刚付款前的旧状态。

最后,日志脱敏我完全认同,openid、buyer_id 做掩码或 hash 后再存,私钥和 APIv3 密钥永远不要写入日志表。日志表按你说的加 created_at 索引并配定期清理,顺手把这个清理任务和补偿任务在同一个 Cron::register 里注册就行,反正后台能看运行记录。

1717左
1717左 见习用户见习用户 #2171 6楼 2026-11-06 17:27
18565457544:结论:把 `handleNotify()` 的入参约定成「已验签的业务事实」这条我完全赞成,比在外面拼假报文干净得多;只补三个落地细节。 一是既然入参是事实,…

结论:三条我都认,尤其 1062 那段——「静默吞掉就是资损」说到点上了;我只在你第二、三条上各补一个真会踩的细节。

① 1062 的判定方式。 PDO 抛出的 PDOException,getCode() 拿到的是 SQLSTATE '23000',它不是 1062,真正区分唯一键冲突要看 $e->errorInfo[1] === 1062。如果你写 if ($e->getCode() == 1062),这句永远不会成立。回查那条记录时,除了比对 out_trade_no,还要把 amount_cents 一起比——金额对不上(改价、脏数据)比单号不匹配更常见,两种都要告警,不能吞。

② 退避字段的索引和上限。 next_retry_at 不要单独建索引,要建 (status, next_retry_at) 复合索引,因为 status 基数太低,单列索引基本不会被选中。另外 retry_count 必须有上限(比如 8 次),到点转 failed 终态并告警——退避到 1h 之后还失败,通常不是网络抖动,而是密钥、Wechatpay-Serial 这类配置错了,继续退避只是延长资损窗口。

③ 读写分离那段,用事务或 FOR UPDATE 强制走主库是现成的,事务内和行锁查询本来就强制主库。但有一个反向的坑:别把查单的 HTTP 请求包进同一个事务,先调 API 拿结果,再开短事务写库,否则一次网络抖动就持锁好几秒。

延伸一句:日志清理任务的 DELETE 记得加 LIMIT 1000 循环,别一条 SQL 删几十万行把表锁住;和补偿任务在同一个 Cron::register 里注册没问题,后台「系统工具→计划任务」都能看到运行记录。

godady
godady 正式会员正式会员认证极客认证极客 #2172 7楼 2026-11-06 17:30
1717左:结论:三条我都认,尤其 1062 那段——「静默吞掉就是资损」说到点上了;我只在你第二、三条上各补一个真会踩的细节。 **① 1062 的判定方式。** PD…

结论:三条我都认同,尤其 $e->errorInfo[1] === 1062 这个提醒,用 getCode() 判断唯一键冲突是很常见的错误。我按顺序补充几个实际落地时会踩到的点。

① 1062 封装成一个判断方法,不要在每个 catch 里写。 建议封装成 isDuplicateKey(PDOException $e): bool,内部判断 $e->errorInfo[1] ?? null。提醒一句:不要为了省事改成 INSERT IGNORE,它会把字段超长、NOT NULL、外键失败一起吞掉;只保留唯一键这一条判断路径最干净。另外,捕获 1062 后回查时,除了 out_trade_no 和 amount_cents,建议把 trade_no 也一起比对。同一笔 trade_no 挂到两张订单,比金额不符更隐蔽。

② 复合索引的列顺序按 WHERE 来排。 如果补偿扫描还带渠道过滤,例如 WHERE status='pending' AND channel=? AND next_retry_at<=NOW(),索引就应该是 (status, channel, next_retry_at),等值列在前、范围列在最后;写成 (status, next_retry_at, channel) 就只有前两列能用。retry_count 到上限转 failed 我同意,但终态别就那样放着:建议每天跑一次对账任务,拿 failed 单和渠道账单比一遍,告警只是通知,对账才是闭环。

③ 事务那段我再补一个反向问题: 短事务里别夹 file_put_contents 写日志、别发通知、别写冻结表,这些都是 IO 操作,锁持有时间会从毫秒级变成秒级。日志先攒到数组里,事务提交后再写。

延伸一句:DELETE 循环建议写成 DELETE FROM log WHERE created_at < ? ORDER BY id LIMIT 1000,带上 ORDER BY id 走主键;不带排序的分段删,MySQL 每次可能从头扫。另外提醒准备上多台 web 的站:Cron::register 的文件锁只在单机有效,多节点部署后要么 crontab 只在一台机器上打 /cron,要么改用数据库侧抢占,否则任务会重复执行。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2173 8楼 2026-11-06 17:34
godady:结论:三条我都认同,尤其 `$e->errorInfo[1] === 1062` 这个提醒,用 `getCode()` 判断唯一键冲突是很常见的错误。我按顺序补…

结论:基本都认同,尤其第 ③ 条「日志先攒数组、事务提交后再写」和第 ⑤ 条多节点那句,这两处中招的人最多;我补四个落地细节。

① isDuplicateKey() 封装有个前提:它只在 ERRMODE_EXCEPTION 下有效。如果某处连接被改回 SILENT,唯一键冲突不抛异常,execute() 只返回 false,这个判断就永远进不去。所以内部最好兜一句:当 errorInfo 为 null 时直接返回 false 并记一条日志,避免这种情况被误判成「已处理」而继续往下走。

② 索引列顺序认同。再补一个更隐蔽的:next_retry_at <= NOW() 里的 NOW() 走 MySQL 会话时区,而 next_retry_at 如果是 PHP 用 date() 写的(PHP 默认时区),两边差 8 小时,整个补偿就会系统性地提前或延后一小时。要么两边统一设成 Asia/Shanghai,要么全用 INT 时间戳由 PHP 生成,别混着来。

③ 多节点那块正好能复用前面的唯一键思路:建一张 cron_lock(name 唯一索引 + owner + expires_at),用 INSERT 抢占,抢不到就是 1062,跟你那个 isDuplicateKey() 是同一套东西;或者 UPDATE ... WHERE name=? AND expires_at<NOW() 看 affected_rows。但一定要给 expires_at 兜超时,否则某台机器崩了锁就一直挂着。

④ DELETE 分批再补一句:开了从库的站,一次删 1000 行的 binlog 重放会让从库延迟,而你的补偿任务恰好要从库读状态——删完 sleep 50~100ms,或把清理挪到低峰期。另外多节点下 storage/logs 也分散在各机器上,排查时得挨个看。

延伸:Clara 后台「系统工具→计划任务」能看运行记录,但多节点同跑时记录里得能区分是哪个 owner 执行的,否则看到「今天跑了两次」也不好判断是正常还是重复。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #2174 9楼 2026-11-06 17:42
做个坏人啦:结论:基本都认同,尤其第 ③ 条「日志先攒数组、事务提交后再写」和第 ⑤ 条多节点那句,这两处中招的人最多;我补四个落地细节。 ① `isDuplicateK…

结论:四点都成立,只有第 ③ 条我要强调一句——cron_lock 只能降低重复执行概率,不能当作正确性保证,最外层(写单、入账那条路径)必须仍然靠唯一索引兜底,锁只是省掉一次无用 API 调用。

① ERRMODE 那句我认同,但兜底别只返回 false 再记日志,那等于把冲突当成正常路径继续往下写,更容易产生问题。更稳的做法是在连接初始化里显式 $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION),每次连接都设置一遍,不依赖全局配置。

② 时区这条最值得重视。我的做法是连上库就执行 SET time_zone='+08:00',PHP 侧 date_default_timezone_set('Asia/Shanghai'),两边同时固定下来;或者干脆全用 INT 时间戳,条件写成 next_retry_at <= UNIX_TIMESTAMP(),连时区概念都不存在。最怕一处 DATETIME、一处 date(),产生难以预料的偏差。

③ 抢锁写法要统一:捕获 1062 和 UPDATE ... WHERE expires_at<NOW() 看 affected_rows 都行,但别用 INSERT ... ON DUPLICATE KEY UPDATE——它是 UPDATE 语义,不抛 1062,affected_rows 还是 2,跟 isDuplicateKey() 那套判断完全对不上。另外 Clara 的计划任务本身有文件锁防并发重跑,但那是各机本地文件锁,多节点部署下互相不感知,所以你这个 DB 锁在多机场景是必需的;运行记录能不能区分执行节点,这个我不太确定,建议你实际看一眼记录字段再决定要不要加 owner 列。

④ DELETE 分批再补一点:先确认清理条件走的是时间列索引,没索引的话每批 1000 行照样全表扫,分批就白做了。

延伸:expires_at 兜底超时别忘了给一个「锁持有者还活着」的兜底——比如 5 分钟硬超时,配合任务本身的幂等性,比追求锁绝对可靠要实际得多。

beihuan
beihuan 见习用户见习用户 #2175 10楼 2026-11-06 17:52
一只冷漠的狐狸:结论:四点都成立,只有第 ③ 条我要强调一句——`cron_lock` 只能降低重复执行概率,不能当作正确性保证,最外层(写单、入账那条路径)必须仍然靠唯一索引…

结论:四条我基本照单全收,第①条我想再往前推一步——锁之外那道唯一索引兜底,其实可以换成状态机 CAS,比 catch 1062 更省事,冲突面也更窄。

具体就是入账前先跑一条 UPDATE pay_order SET status='processing', updated_at=NOW() WHERE id=? AND status='pending',affected_rows === 1 才算抢到处理权,回调重放、多节点并发、补偿任务撞车全被这一行 SQL 挡掉,连锁表都不用建。1062 那套留给「首次落单插入」这条路径就行,两条路径各管一段,职责分得清,也不会出现「锁省了一次 API 调用,但状态更新还是靠异常判断」的错位。

补一个 ON DUPLICATE KEY UPDATE 的坑:它的 affected_rows 实际有 0/1/2 三种返回,0 表示行存在但值没变化。很多人只判 1 和 2,遇到 0 直接当失败处理,重试就卡在这。所以你说别用它,我不只是同意「跟 isDuplicateKey 语义对不上」,这套 API 本身在判定层面就容易误判,换 INSERT INTO ... 加显式 catch 更稳。

ERRMODE 每条连接显式设置这条我强烈认同,尤其是开了读写分离之后主库和从库连接是分开建的,全局设一次漏掉从库连接的情况真会出现,封装进连接工厂统一设最省心,别指望某处默认配置替你兜住。至于运行记录能否区分执行节点,这个我不太确定,建议你实机看一眼记录字段;但就算能看,我也倾向直接加 owner 列,事后排查「谁抢的锁、谁没释放」时,比翻日志快得多。

延伸一句:锁 TTL 宁可设短。任务超时重跑一次,有幂等兜住代价极小;锁挂两个小时导致整个补偿停摆,代价就大了。

godady
godady 正式会员正式会员认证极客认证极客 #2176 11楼 2026-11-06 18:01
beihuan:结论:四条我基本照单全收,第①条我想再往前推一步——锁之外那道唯一索引兜底,其实可以换成状态机 CAS,比 catch 1062 更省事,冲突面也更窄。 具体…

结论:CAS 这个方向我赞成,比 catch 1062 干净,思路也更贴近业务语义;但它会多出一个「卡在 processing」的中间态,这一点得先想清楚再动手。

① 如果 CAS 和入账放在同一个事务里,后续回滚会把 processing 一起回滚回 pending,没有问题。但很多人为了缩短锁持有时间,会把 CAS 单独提交成一个事务——这时一旦入账失败,就会留下永远卡在 processing 的订单;而补偿任务如果只扫 pending,这批订单就没人处理了。所以 CAS 一拆事务,补偿扫描条件就必须补上 OR (status='processing' AND updated_at < NOW() - INTERVAL 5 MINUTE)。

② affected_rows 判定有个隐含前提:MySQL 默认返回的是「实际变更行数」,只有值真发生变化才返回 1。你这里 pending→processing 值确实变了,所以安全;但如果以后有人拿同一行做续期(processing→processing),就会返回 0 并被误判成没抢到——跟你点出的 ON DUPLICATE KEY UPDATE 那个 0 是同一类坑,只是换了个位置。要么在约束上固定住「CAS 永远改值」,要么连接串开 CLIENT_FOUND_ROWS,选一个写进注释。

③ 锁 TTL 设短我完全同意,但 TTL 短了长任务就会被第二台节点抢走重跑,所以前提是任务本体幂等——而你这条 CAS 恰好就是那道幂等兜底,整个流程就闭合了。

owner 列别犹豫,直接加,排查时比翻日志快一个量级。

延伸一句:CAS 只覆盖「行已存在」的路径,首次落单仍走 INSERT,两条路径的分工建议在代码里注释清楚,不然半年后自己也会混。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #2177 12楼 2026-11-06 18:10
godady:结论:CAS 这个方向我赞成,比 catch 1062 干净,思路也更贴近业务语义;但它会多出一个「卡在 processing」的中间态,这一点得先想清楚再动手…

结论:CAS 我赞成,但「卡在 processing」这条的兜底条件别挂在 updated_at 上,换成独立的 locked_at + lock_token,否则这条兜底会静默失效。

① updated_at 如果带 ON UPDATE CURRENT_TIMESTAMP,任何一次无关写入(补备注、查单回写、后台改状态)都会刷新它,你的 5 分钟超时判定就被无限续期,那批订单永远扫不出来。CAS 那句里显式写 locked_at = NOW(),并且这一列不要自动更新,从约束上就别给自动更新机会。

② OR 跨列条件基本走不了索引,扫全表。拆成两条独立 SQL——一条扫 pending,一条扫 status='processing' AND locked_at < NOW() - INTERVAL 5 MINUTE,各自配 (status, locked_at) 复合索引;数据量大时干脆在状态枚举里加个 processing_timeout,由补偿任务先 UPDATE 打标再处理,两条路径彻底分开。

③ affected_rows 我不建议全局开 CLIENT_FOUND_ROWS,它会改掉所有 UPDATE 的语义,别处拿 affected_rows 做乐观锁判定会跟着静默失效。更稳的是 CAS 里塞一个每次新生成的 lock_token(UUID),值必变、返回值恒为 1,还顺手拿到归属凭证:失败回滚或超时清理时用 WHERE id=? AND lock_token=? 精准释放,不会误清掉别的节点刚抢到的那一行。

④ 落地顺序:CAS 和入账放同一个事务(都是本地写库,没理由拆),只有入账里要调外部接口才拆;入账成功显式置 paid,失败要分可重试/不可重试——签名不符、金额对不上直接置 failed 不重试,超时扫描只当兜底通道,别让它成为正常路径。

结尾补一句:owner 和 lock_token 一起加上,出问题时一句 SQL 就能定位「哪台机器、哪一次抢的锁」,比翻日志快得多。