记账事务化:并发下悬赏发放不双花的设计解析
悬赏不双花的答案只有一句话:钱在发帖那一刻就已经从楼主余额里划走并冻结,采纳只是决定这笔已冻结的钱归谁——所以并发下不存在「两次从同一个余额里扣钱」的窗口。
先看清资金流的三段,才知道竞态发生在哪
Clara BBS 的悬赏问答在流程上是三段时间:发布托管、采纳发放、取消退款。结论:真正的扣款动作只发生在第一段,后两段都只是「已冻结资金的转移」。
具体说,楼主发悬赏帖时设置悬赏额,系统会立刻从其余额中托管冻结这笔钱,此时钱已经不在楼主的可用余额里了。社区有人回答后,楼主采纳最佳答案,这笔冻结款才发放给被采纳者;在没有采纳之前,楼主可以取消悬赏,冻结款原路退回。这个「先离开余额、再决定归属」的顺序,就是把竞态窗口从「采纳」前移到了「发布」。
为什么要这么设计?因为如果做成「采纳时才从楼主余额扣钱」,那么并发下会立刻出现两个问题:一是两个请求同时判定余额充足、同时扣款,造成超额支出;二是楼主可以在采纳的同一瞬间把钱转走,导致账务悬空。托管冻结让这两个问题在发布环节一次性解决。
采纳必须是一次带条件的原子状态更新
防重的关键不在 PHP 里的 `if`,而在数据库的那一行。结论:采纳接口第一次写库,必须是一条带状态条件的更新语句,靠影响行数判断谁抢到了。
做法是给悬赏帖维护一个结算状态,例如「未结算 / 已采纳 / 已取消」。采纳时的第一步不是查余额、也不是查回答,而是先执行类似 `UPDATE 悬赏帖 SET status='已采纳', accepted_reply_id=? WHERE id=? AND status='未结算'` 的语句。数据库会保证只有一条请求拿到「影响行数 = 1」,另一条并发请求拿到 0,直接返回「该悬赏已结算」,后面的发钱逻辑根本不会进入。
注意点有三个:第一,状态判定和状态写入必须在同一条 SQL 里,先 `SELECT` 查状态、再 `UPDATE` 是典型的两步竞态,等于没防;第二,不要在业务层用文件锁或缓存锁当唯一防线,缓存可能被清(系统本身就是保存即生效、无编译缓存的模型),数据库行才是最终事实源;第三,前端的按钮置灰和防连点只是体验优化,真正的兜底永远是那条带条件的更新。
状态更新、划款、流水、通知要在同一个事务里
结论:一条悬赏结算涉及的所有账务写入,必须同进同出,不能出现「状态改了但钱没到」的中间态。
Clara BBS 的货币体系是统一记账的——悬赏、转账、卡密兑换、签到奖励、礼物、附件交易走的都是同一套货币记账 API,插件调用也是这个入口。这个统一入口的价值在并发场景下特别明显:一处保证事务性,全站受益。
一次采纳结算通常包含:更新悬赏状态、扣减楼主侧的冻结金额、增加被采纳者的可用余额、写入对应条数的货币流水记录。这些放在一个事务里提交,中途任何一步失败则整体回滚,状态仍是「未结算」,用户刷新后可以重试,不会出现钱凭空消失或凭空多出的记录。
至于通知,建议放在事务提交之后发送,或者允许独立失败重试——把「发通知」这种外部副作用塞进账务事务,一旦通知环节异常,会把已经正确的账务一起拖回滚,反而制造新的不一致。
几个必须处理干净的边界
第一,不能采纳自己的回复,这条限制要在服务端校验,前端隐藏按钮不算数。第二,取消悬赏与采纳之间存在竞争:如果两者都走同一套「带条件状态更新」,先到者改状态成功,后者自动失败,逻辑天然互斥。第三,重复提交或网络重试导致同一请求到达两次,第二次会因为状态不再是「未结算」而被挡掉,这正是状态条件更新的额外红利。
自查方法也很简单:后台「货币管理」里翻一遍流水,看悬赏相关的记录是否成对出现(支出/冻结一条、发放或退款一条),总数是否对得上;再用两个浏览器同时点采纳试一次,如果只有一边成功、另一边提示已结算,这套机制就是有效的。
总结一下:悬赏不双花靠的不是加锁玄学,而是三件事的组合——发布即托管冻结把扣款窗口前移、带状态条件的原子更新保证采纳只成功一次、统一记账 API 把状态与资金放进同一个事务。任何一环缺失,并发压上来都会漏。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





