支付回调防重:Redis 锁配合数据库约束
文章针对支付回调至少一次投递引发的并发重复处理,介绍 Redis 原子加锁、校验锁归属的 Lua 脚本及数据库唯一约束。方案通过多层幂等控制降低重复插入与重复扣款风险。
AI 深度解读
支付 webhook 最麻烦的情况,是同一笔交易的通知同时落到多个服务实例上:每个请求都以为自己是第一次处理,最终重复入账,甚至重复触发扣款。原文给出的方案是 Redis 锁、带所有权校验的解锁脚本,以及数据库唯一约束。
这套分层思路可以借鉴,但示例距离作者声称的「可直接生产使用」还有明显差距。读它最有价值的地方,是分清三个问题:谁正在处理、谁已经处理成功,以及什么时候才能向支付平台返回成功。
同一笔交易为什么需要两道防线
原文以支付服务商(PSP)的至少一次投递(at-least-once delivery)为前提。网络中断、请求超时、上游重试,都可能让相同通知重复到达,而且可能同时到达不同线程、不同容器。
因此,先查数据库、没有记录再插入,并不能自动保证安全。两个请求可能同时查到「不存在」,随后各自执行写入。真正需要约束的是并发执行过程。
作者选择用不可变的 transaction_id 作为锁的粒度。请求处理前,先执行 SET ... NX EX:NX 表示键不存在时才写入,EX 设置过期秒数。判断与写入由 Redis 原子完成,避免应用自己先检查、再设置时产生竞争。
下面这段代码为交易生成锁键和所有权令牌,再尝试取得一个有效期为 5 秒的锁:
lock_key = f"lock:webhook:tx:{payload.transaction_id}".encode("utf-8")
lock_value = str(uuid.uuid4()).encode("utf-8")
lock_expire_seconds = 5
acquired = redis_client.set(
name=lock_key,
value=lock_value,
nx=True,
ex=lock_expire_seconds
)
重点看 nx=True 与 ex=lock_expire_seconds:互斥和过期必须随同一次写入设置。这里的 5 秒只是示例参数,原文没有给出业务耗时分布,也没有证明它覆盖了最慢处理时间。
Redis 锁负责减少同一交易的并发执行,数据库则承担最终的数据约束。作者要求对 transaction_id 建立 UNIQUE 约束,即使锁因过期、网络分区或异步副本故障切换失效,重复插入仍会被数据库拒绝。
锁限制正在发生的竞争,唯一约束阻止重复记录真正落库。
不过,这个保证有边界。原文的 process_payment_in_database 只是一个 pass,没有实现数据库事务,也没有展示实际扣款调用。如果业务还会请求外部支付接口,数据库里不出现重复记录,并不足以证明外部扣款不会执行两次。
解锁为什么必须确认「这把锁还是我的」
锁有过期时间,就可能出现这样的交错:
第一个请求取得锁,随后因 GC 暂停或数据库变慢,超过 5 秒仍未完成。锁过期后,第二个请求取得同名锁。此时第一个请求恢复执行,如果直接删除锁键,就会把第二个请求的锁删掉。
作者为每次加锁生成 uuid.uuid4() 令牌,解锁时同时提交锁键和自己的令牌。下面的 Lua 脚本只在令牌一致时删除键:
UNLOCK_LUA_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
关键是 get 的比较与 del 在同一次脚本执行中完成。拆成两次客户端请求,中间仍可能发生锁过期和重新获取。
原文用 redis_client.eval(UNLOCK_LUA_SCRIPT, 1, lock_key, lock_value) 调用脚本,其中 1 表示后面有一个键参数。Redis 客户端连接 localhost:6379、使用 db=0,并设置 decode_responses=False 保留字节响应。
这段脚本解决的是误删别人的锁。它不会延长锁的寿命,也不会在锁过期后自动终止原来的任务。因此,所有权安全的解锁,并不等于整个业务始终保持互斥。原文没有实现续租,也没有展开过期后如何限制旧任务继续写入。
返回 HTTP 200 前,必须知道业务到了哪一步
原文对两种情况直接返回 HTTP 200 OK:一是没有取得锁,二是数据库处理抛出异常。前者被视为已有请求正在处理,后者被视为唯一约束冲突后的幂等成功。
这两处都需要收紧。
没有取得锁,只能说明此刻有人持锁,不能说明交易已经完成。如果重复请求先收到 200,持锁请求随后失败,而系统又没有可靠的重试或恢复机制,这次成功应答就缺少业务结果支撑。是否可以这样处理,要结合 PSP 的应答约定、原请求失败后的重投机制,以及服务端是否已经持久化接收事件来判断。
更直接的问题是数据库异常捕获范围。示例使用 except Exception,把所有异常都返回为「交易已记录」。数据库断连、超时、代码错误也会进入这个分支。它们与重复交易完全不是一回事。
只有确认交易已经成功,或事件已经进入可靠的后续处理流程,成功应答才有依据。
数据校验部分也有类似的宣传与实现落差。依赖声明是 redis>=5.0.0、orjson>=3.9.0、pydantic>=2.0.0;处理流程先执行 orjson.loads(raw_body),再通过 WebhookPayload(**parsed_data) 校验字段。amount 要求为大于 0 的整数,采用最小货币单位,currency 默认 JPY,transaction_id 会去除首尾空白并拒绝空值。
这些是有用的输入约束,但代码没有显式开启 Pydantic 严格模式,不能仅凭使用 v2 就称为严格类型校验。「零开销」也没有测量依据。默认 JPY 是否合适、交易标识能否统一去除空白,都需要支付平台协议支持。
我的判断
我认可 Redis 降低重复执行、Lua 避免误解锁、数据库唯一约束兜底的分层思路。但对于处理量不高、业务集中在单个数据库事务里的服务,直接依靠唯一约束处理重复写入,也是应该比较的方案。增加 Redis 会带来新的连接、故障和超时处理成本,原文没有给出吞吐、延迟或竞争测试数据,无法证明它一定更划算。
作者声称静态分析、单元测试和 schema 检查发现了零逻辑缺陷,随后展示的运行结果却是导入阶段失败:
ModuleNotFoundError: No module named 'redis'
这只能证明目标解释器缺少 redis,不能证明并发支付流程通过了验证。作者推测原因是没有执行 pip install -r requirements.txt,或者脚本用了错误的 Python 解释器;原文没有进一步确认是哪一种。
把依赖安装、解释器选择和测试启动纳入 CI,是合理的工程建议。pip check 可以检查已安装依赖之间的兼容性,再配合实际导入验证,但它不能替代业务测试,更不能证明幂等成立。
原文末尾关于多智能体协议、token 与能耗下降的宣传,也没有提供可核查的实验方法。它与支付方案的正确性没有直接关系,不应拿来补足验证证据。
能拿来做什么
- 给 Node 支付回调梳理幂等键,明确
transaction_id是否在当前数据库范围内唯一,并让数据库约束真正覆盖这个范围。若还包含外部扣款调用,需要单独检查该调用的幂等机制。 - 将「处理中」「已经成功」「处理失败」作为不同结果处理。只有明确识别到目标唯一约束冲突,才进入重复成功分支;数据库断连和超时应进入失败恢复流程。
- 为日常 AI 生成的 webhook 代码补上故障验证:并发投递同一交易、处理超过 5 秒、数据库写入失败、旧任务恢复后解锁。这些检查比看到
NX和 Lua 就认定安全更有效。 - 在 CI 中先用目标 Python 解释器安装依赖,再执行
pip check、导入验证和功能测试。把「代码能被导入」与「支付流程处理正确」分开验收。