
Go实战:支付系统设计摘要: 本篇讲解Go支付系统设计实现支付网关对接多渠道幂等性保证防止重复扣款对账系统核对资金流水回调重试机制处理通知失败分享重复回调导致重复扣款的踩坑经验对比同步扣款、异步回调、本地消息表三种支付方案。开篇故事去年我们接了一个商城项目支付系统上线第一周财务对账发现账目对不上。某天微信支付多扣了3笔款用户投诉要求退款。排查发现微信的支付回调因为网络超时重试了3次我们的系统没有做幂等校验每次回调都执行了一次扣款同一个订单扣了3次。退款赔了违约金不说更怕的是用户信任度下降。我花了两天把支付系统重新梳理重点解决幂等性和回调重试两个问题。这次我把支付系统的核心设计写出来。一、支付网关设计支付网关是对接多个支付渠道(微信、支付宝、银联)的统一入口。上层业务只调网关一个接口网关负责路由到具体渠道屏蔽各渠道的差异。packagepaymentimport(contexterrorsfmt)// PayRequest 支付请求typePayRequeststruct{OrderIDstring// 业务订单IDAmountint64// 支付金额(分)Channelstring// 支付渠道: wechat/alipayUserIDstring// 用户IDReturnURLstring// 支付完成回调URL}// PayResponse 支付响应typePayResponsestruct{PayURLstring// 支付链接(二维码或跳转URL)PrepayIDstring// 预支付IDChannelstring// 实际支付渠道}// PaymentGateway 支付网关// 对接多个渠道统一接口typePaymentGatewaystruct{channelsmap[string]Channel// 渠道集合}// Channel 支付渠道接口// 每个渠道实现自己的支付逻辑typeChannelinterface{// Pay 发起支付Pay(ctx context.Context,req*PayRequest)(*PayResponse,error)// QueryOrder 查询订单状态QueryOrder(ctx context.Context,orderIDstring)(string,error)// Name 渠道名称Name()string}// NewPaymentGateway 创建支付网关funcNewPaymentGateway()*PaymentGateway{returnPaymentGateway{channels:make(map[string]Channel),}}// RegisterChannel 注册支付渠道func(g*PaymentGateway)RegisterChannel(ch Channel){g.channels[ch.Name()]ch}// Pay 统一支付入口// 根据请求中的渠道字段路由到具体实现func(g*PaymentGateway)Pay(ctx context.Context,req*PayRequest)(*PayResponse,error){ch,ok:g.channels[req.Channel]if!ok{returnnil,fmt.Errorf(不支持的支付渠道: %s,req.Channel)}// 调用渠道实现returnch.Pay(ctx,req)}// WechatChannel 微信支付渠道(示例实现)typeWechatChannelstruct{appIDstringmchIDstringapiKeystring}// NewWechatChannel 创建微信支付渠道funcNewWechatChannel(appID,mchID,apiKeystring)*WechatChannel{returnWechatChannel{appID:appID,mchID:mchID,apiKey:apiKey}}func(w*WechatChannel)Name()string{returnwechat}// Pay 微信支付下单func(w*WechatChannel)Pay(ctx context.Context,req*PayRequest)(*PayResponse,error){// 实际调用微信支付API下单// 这里简化为返回支付链接ifreq.Amount0{returnnil,errors.New(支付金额必须大于0)}returnPayResponse{PayURL:weixin://wxpay/bizpayurl?prreq.OrderID,PrepayID:wx_req.OrderID,Channel:wechat,},nil}// QueryOrder 查询微信订单状态func(w*WechatChannel)QueryOrder(ctx context.Context,orderIDstring)(string,error){// 实际调用微信支付查询APIreturnSUCCESS,nil}网关的好处是业务层不关心用哪个渠道。新增渠道只要实现Channel接口注册进去业务代码零改动。网关还负责签名校验、参数组装这些公共逻辑。二、幂等性保证支付系统最容易出问题的环节是回调。第三方支付渠道的回调不是只发一次网络超时会重试发多次。如果系统不做幂等每次回调都扣款用户就被多扣了。幂等的核心是同一个请求处理多次结果和一次一样。packagepaymentimport(contexterrorsfmttimegithub.com/redis/go-redis/v9)// IdempotentService 幂等性服务// 用Redis记录已处理的回调防止重复处理typeIdempotentServicestruct{client*redis.Client}// NewIdempotentService 创建幂等服务funcNewIdempotentService(client*redis.Client)*IdempotentService{returnIdempotentService{client:client}}// CallbackRequest 回调请求typeCallbackRequeststruct{OrderIDstring// 订单IDChannelstring// 支付渠道Amountint64// 支付金额Statusstring// 支付状态CallbackNostring// 渠道回调流水号(唯一)Time time.Time}// HandleCallback 处理支付回调// 幂等: 同一回调处理多次只扣款一次func(s*IdempotentService)HandleCallback(ctx context.Context,req*CallbackRequest,)error{// 用回调流水号作为幂等键// 渠道回调都有唯一流水号用它做防重key:fmt.Sprintf(pay:callback:%s:%s,req.Channel,req.CallbackNo)// SETNX设置幂等键过期时间24小时// 设置成功说明是第一次处理设置失败说明已处理过ok,err:s.client.SetNX(ctx,key,1,24*time.Hour).Result()iferr!nil{returnfmt.Errorf(幂等检查失败: %w,err)}if!ok{// 已经处理过这个回调直接返回成功// 告诉渠道已处理渠道就不会再重试returnnil}// 首次处理执行业务逻辑// 更新订单状态、扣减库存、通知下游等iferr:s.processPayment(ctx,req);err!nil{// 处理失败删除幂等键允许重试s.client.Del(ctx,key)returnerr}returnnil}// processPayment 执行支付成功后的业务处理func(s*IdempotentService)processPayment(ctx context.Context,req*CallbackRequest,)error{// 1. 校验金额是否匹配// 2. 更新订单状态为已支付// 3. 扣减库存// 4. 通知下游业务// 这里简化处理ifreq.Status!SUCCESS{returnerrors.New(支付未成功)}returnnil}幂等键的选择很关键。用订单ID做幂等键有风险同一个订单可能在不同渠道有多次支付尝试。用渠道回调流水号做幂等键最安全每个流水号只处理一次。24小时过期是兜底回调重试一般几分钟内就结束。三、踩坑经验:重复回调导致重复扣款这个坑我在开篇提到过。微信支付回调重试3次系统没有幂等校验同一个订单扣了3次款。回顾整个过程有几个教训。第一个教训是回调没有幂等。当时图快回调接口直接处理支付成功逻辑没加防重。微信的回调机制是发出回调后等响应5秒内没收到成功响应就重试最多重试8次。我们处理回调时调下游服务超时了微信等不到响应就重试每次重试又触发一次扣款。第二个教训是金额没有校验。回调里的金额和下单时的金额没做比对攻击者可以伪造低金额回调。修复方案除了加幂等还要做金额校验和签名校验。packagepaymentimport(crypto/sha256encoding/hexerrorsfmt)// CallbackValidator 回调校验器// 校验签名和金额防止伪造回调typeCallbackValidatorstruct{apiKeystring// 渠道API密钥}// NewCallbackValidator 创建校验器funcNewCallbackValidator(apiKeystring)*CallbackValidator{returnCallbackValidator{apiKey:apiKey}}// Validate 校验回调签名和金额func(v*CallbackValidator)Validate(req*CallbackRequest,expectedAmountint64,signaturestring,)error{// 1. 校验签名防止回调被篡改sign:v.calcSign(req)ifsign!signature{returnerrors.New(回调签名校验失败)}// 2. 校验金额防止低金额伪造ifreq.Amount!expectedAmount{returnfmt.Errorf(金额不匹配: 期望%d, 实际%d,expectedAmount,req.Amount,)}// 3. 校验订单状态防止重复支付ifreq.Status!SUCCESS{returnerrors.New(支付状态非成功)}returnnil}// calcSign 计算回调签名// 拼接关键字段做SHA256防止篡改func(v*CallbackValidator)calcSign(req*CallbackRequest)string{raw:fmt.Sprintf(%s|%s|%d|%s,req.OrderID,req.Channel,req.Amount,v.apiKey)h:sha256.Sum256([]byte(raw))returnhex.EncodeToString(h[:])}回调处理的标准流程是: 先验签名防篡改再验金额防伪造再查幂等防重复最后执行业务。三道校验都过了才真正扣款。我们加了这个流程后再没出过重复扣款。四、对比分析支付方案一致性性能复杂度适用场景同步扣款高低低小额即时支付异步回调中高中大部分电商本地消息表极高中高对账要求高TCC事务极高低极高跨服务支付同步扣款是支付和业务在同一事务里完成一致性高但性能差。异步回调是主流方案支付渠道回调通知结果性能好但需要幂等保证。本地消息表把支付结果先写本地表再异步通知下游一致性极高但实现复杂。TCC事务适合跨服务的分布式支付一致性最好但性能开销大。总结支付系统的核心是幂等和安全。回调必须做幂等用渠道流水号做幂等键防止重复处理。回调必须验签名和金额防止伪造篡改。支付网关统一对接多渠道业务层不感知渠道差异。对账系统定期核对资金流水发现差异及时处理。下一篇我们聊短链接服务的设计。