最初的版本是把支付信息直接挂在订单表上——订单表里存 pay_channel、pay_status、trade_no。两个问题很快暴露出来。
一是重试没法做。用户信用卡被拒后想换 PayPal 再付一次,这时候订单还是那一笔,但支付信息要换一套。挂在订单上就只能覆盖掉旧的,失败的那次支付记录就丢了,事后查不清用户到底试了几次、每次为什么失败。
二是职责混在一起。订单要管商品、价格、收货信息、状态流转;支付要管渠道、验签、回调、退款。混在一张表和一个服务里,改支付逻辑要动订单代码,风控和对账也无从下手。
所以拆成三域:订单域管交易意图(买什么、多少钱、给谁),支付域管收款过程(用哪个渠道、支付单的状态),账务域管资金记录(复式记账、可审计)。订单和支付是一对多,这是整个设计的核心判断。
要点是回调只做最小的事(验签、落库、发消息)然后立刻返回,后面的扣库存、发货、记账全部异步。因为渠道对回调响应时间有要求,同步处理业务会导致渠道判定超时而不断重发。
LOCKED 并绑定订单号,支付成功只是把状态从锁定转成已发放。如果等支付成功才去分配,会出现「付了钱但卡密被别人抢走」——这是最难处理的客诉,因为钱已经收了。为什么不用分布式事务。链路里有外部渠道这个参与者,你没法让它加入你的事务;而且链路长(订单、支付、库存、账务、通知),2PC 会让资源长时间锁定,跨境场景下的网络延迟让这个代价更不可接受。所以选最终一致:强一致只保证在单库事务内能做到的部分(扣库存和分配卡密必须强一致,不能超发),跨服务的部分用可靠消息加补偿加对账。
为什么回调要接收与处理解耦。这是那个耗时数字的来源。改造前回调里同步做完发货和记账才返回,P99 到了三秒多,而渠道的超时阈值通常是几秒——网络稍差就被判定为通知失败然后重发,重发又进一步加剧拥塞,形成正反馈。改成落库加发 MQ 后立刻返回,响应时间降到几十毫秒,重发问题自然消失。
踩过的坑:关单和支付撞车。订单超时关闭的那一瞬间用户完成了支付,结果订单已关闭但钱收了。解法是三层:关单前主动查一次渠道状态;关单和支付成功都用 update ... where status = 前置状态 靠数据库行锁互斥;最关键的是兜底——已关闭的订单收到支付成功通知时,走「异常支付」流程自动发起退款并告警,绝不能默默忽略。
P99 由约 1.2s 降至 180ms。改造前后各跑一轮压测对比即可。改造前那一秒多主要花在同步的扣库存、发货、记账上;改造后只剩验签、落库、发 MQ,剩下的一百多毫秒是网络往返加偶发的 GC 抖动——所以它不会低到几十毫秒。
为什么用 P99 而不是平均值:渠道判定超时看的是尾部延迟,平均值会把问题掩盖掉。这个点面试官会追问,要能答上来。
压测 50 QPS 下未再出现重复通知。验证方式是统计回调接收表里同一个渠道流水号出现的次数——原始报文都落了库,同一流水号出现多次就是重发。注意结论要限定在压测条件下,不要说成线上永远不会发生。
update pay_order set status = ? where id = ? and status = ?,判断影响行数。前置状态写在 where 里,靠数据库的行锁保证互斥和幂等。加框架(比如 Spring StateMachine)在这个规模下是过度设计——状态只有五六个,流转规则写在一个枚举里加校验方法就够了,而且这样出问题时排查路径极短。如果状态机复杂到有并行状态和嵌套子状态,才值得上框架。
虚拟商品的库存和普通电商不是一回事。普通商品库存是一个可加减的数字,卡密是有具体身份的实体——每一张卡密都是唯一的、有价值的、发错了收不回来。所以它同时具备三个难点:并发分配的正确性、敏感数据的安全性、以及缺货即事故的供应链压力。
最早的实现是「select 一张未使用的卡密 → update 标记为已用」,测试环境完全正常,上线后促销时出现同一张卡密发给两个用户。因为这两条语句之间有并发窗口,两个请求会查到同一张。
update ... where status = 'UNUSED' limit 1 然后判断影响行数,靠数据库行锁保证同一张卡密不可能被两个请求拿到。这里不需要分布式锁——很多人第一反应是加 Redis 锁,但那是在数据库已经能保证原子性的情况下多引入一个失效点(锁超时、主从切换丢锁),是典型的过度设计。UNUSED → LOCKED → SENT,另有 VOID 作废。每次流转都写变更流水(谁、什么时候、因为哪个订单、从什么状态变成什么状态)。这张流水表是后来排查「卡密发重了」类问题的唯一依据,没有它根本查不清。SENT 的绝不能放回池子。为什么没上 Redis 预加载卡密队列。高并发场景下把卡密 ID 预热进 Redis List 用 lpop 取,性能确实更好。但它引入了「Redis 取出成功但落库失败」这个新的一致性问题,而且 Redis 挂了要能回退到数据库模式,代码复杂度翻倍。我们的峰值 QPS 只有几百,数据库方案完全撑得住,所以选了简单可靠的那个。这个取舍要主动说——面试官想看的是你会不会为了炫技过度设计。
可售数量为什么不单独维护计数。试过用一个 stock_count 字段做快速拦截,但它和实际卡密数会不一致(并发、补偿失败),最后变成两个数据源互相打架。改成以实际卡密表为准,前端展示用模糊表述(「库存紧张 / 充足」)而不是精确数字——反正精确库存对用户没意义,还暴露经营数据。
压测 50 并发下重复分配未再复现。做法是写脚本对同一个 SKU 并发发起 50 次下单,然后查有没有两条订单绑定了同一个卡密 ID。旧写法(先查后改)每轮压测都能查出几笔,改成单条 SQL 后连续跑多轮都没再出现。并发 bug 只能这么验证,这也是这个模块最该写的量化。
缺货导致的「已支付无法发货」从每周数次降到月均一次以内。数据来源是异常订单队列——这类订单会被单独打标并告警,条数可查。口径用周和月而不是日均,因为小体量项目按日均算出来是零点几单,反而不自然。
被问到时要能补一句统计范围(比如「上线后观察了两个月」),限定范围的表述比绝对化的表述站得住。
update ... limit 1 在并发下真的安全吗?会不会两个事务都更新成功?
A:不会。这条语句在执行时会对扫描到的行加排他锁,第二个事务要么等锁释放后发现 status 已经不是 UNUSED 而更新 0 行,要么锁到另一行。关键在于判断条件和更新动作在同一条语句里,中间没有窗口。反过来「先 select 再 update」之所以出错,就是因为两条语句之间的间隙不受保护。
(sku_id, status) 建联合索引减少扫描范围;把同一 SKU 的卡密按分片键打散,请求随机落到一个分片上分散竞争;再往上才是 Redis 预加载。我们当时的量级用索引优化就够了,所以没继续往下做——如果面试官继续追问,我会说清后面两级方案的成本和适用条件。
前面所有的幂等、重试、状态机都是降低出错概率,但概率不为零。跨境支付链路涉及渠道、银行、汇率、多个内部服务,任何一环的漏网都会变成资金差异。没有对账的支付系统一定有隐藏资损,只是还没被发现。
更现实的驱动是:出现资金问题时,如果只能靠用户投诉才知道,说明系统对自己的正确性一无所知。这在面试里是个很好的表达点——它体现的是从「被动响应」到「主动发现」的意识转变。
为什么补偿任务的扫描间隔是 5 分钟而不是 1 分钟。更短的间隔能更快自愈,但会频繁扫描订单表并调用渠道查询接口——渠道的查询接口是有频率限制的,打太频繁会被限流甚至封禁。5 分钟是在用户感知(大部分用户不会在 5 分钟内投诉)和渠道压力之间取的平衡。
为什么对账要单独存渠道原始账单。不只是为了排查,更是为了纠纷举证。国际支付的拒付(chargeback)争议中,需要向渠道提交完整的交易凭证,原始账单是最有力的材料。这一点是被一次真实的拒付争议教育的。
规模上的诚实说明。当前是单机跑批,数据量在几十万笔量级完全够用。如果量级上到千万,就要上分片批处理(比如 XXL-Job 的分片广播)。面试时主动说清方案的适用边界,比假装它能扛任何量级更可信。
一次性清理出 37 笔历史差异单。来源是对账任务第一次全量跑完后的输出条数,符合「上线一个新能力先把存量问题捞一遍」的真实过程。这是个绝对数,被问到时只需要说清「这 37 笔里长款几笔、短款几笔、金额差异几笔,各自怎么处理的」——所以真要写它,得能报出这个分布。
差异报表日均条目从十余条降到 1 至 2 条。数据来自对账系统输出的待处理差异条数,取容差核销上线前后各两周对比。关键是要说清降下来的那部分是误报(手续费和汇率造成的合理差异),不是真实差异变少了——这个区分如果不主动讲,被追问出来会很尴尬。
「把发现方式从依赖用户反馈改为系统主动识别」是纯定性表述,但它是这个模块最有价值的部分。面试官真正关心的是你有没有「系统要能自己知道自己出错」的意识,这个用一句话就能传达,不需要百分比。
没有匹配的内容,换个关键词试试。
项目拆解 · 国际虚拟商品交易平台(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据