V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
YunFun
V2EX  ›  程序员

面试实录 —— 电商支付系统中,如何有效避免用户重复支付?

  •  
  •   YunFun · 10 小时 18 分钟前 · 3540 次点击

    面试官:在电商支付系统中,如何有效避免用户重复支付?请详细阐述你的设计思路。

    应试者:防止重复支付是电商支付系统的核心设计挑战之一。我的解决方案主要从以下几个维度考虑:

    唯一性标识设计

    在支付流程中,我们需要为每笔交易生成全局唯一且可追溯的幂等标识:

    type PaymentIdentifier struct {
        OrderID       string    // 订单 ID
        UserID        int64     // 用户 ID
        TransactionID string    // 全局唯一事务 ID
        CreatedAt     time.Time // 创建时间
    }
    
    // 生成全局唯一事务 ID
    func GenerateTransactionID() string {
        // 使用雪花算法生成分布式唯一 ID
        return snowflake.Generate().String()
    }
    

    幂等性控制机制

    核心实现思路:

    type PaymentService struct {
        // 分布式锁,防止并发冲突
        locker distributed.Locker
        
        // 已处理交易的缓存
        processedTransactions *sync.Map
        
        // 数据库连接
        db *gorm.DB
    }
    
    func (s *PaymentService) ProcessPayment(ctx context.Context, payment *Payment) error {
        // 1. 获取分布式锁
        lock, err := s.locker.Lock(payment.TransactionID)
        if err != nil {
            return errors.Wrap(err, "获取锁失败")
        }
        defer lock.Unlock()
        
        // 2. 检查交易是否已处理
        if _, processed := s.processedTransactions.Load(payment.TransactionID); processed {
            return errors.New("交易已处理")
        }
        
        // 3. 数据库层面的幂等性检查
        var existingPayment Payment
        if err := s.db.Where("transaction_id = ?", payment.TransactionID).First(&existingPayment).Error; err == nil {
            return errors.New("重复交易")
        }
        
        // 4. 执行支付逻辑
        if err := s.executePayment(payment); err != nil {
            return err
        }
        
        // 5. 记录已处理交易
        s.processedTransactions.Store(payment.TransactionID, true)
        
        return nil
    }
    

    多层幂等性保障

    • 客户端:生成唯一请求 ID
    • 网关层:请求去重
    • 服务端:事务幂等
    • 数据库:唯一约束

    面试官:能详细解释一下你提到的多层幂等性保障吗?每一层具体是如何实现的?

    应试者:多层幂等性保障是一种分层防重复提交的策略:

    客户端层

    type PaymentRequest struct {
        RequestID     string    // 客户端生成的唯一请求 ID
        OrderID       string
        Amount        decimal.Decimal
        PaymentMethod string
    }
    
    func GenerateClientRequestID() string {
        // 结合时间戳、随机数、设备 ID 等
        return fmt.Sprintf("%s-%d-%s", 
            time.Now().Format("20060102150405"),
            rand.Int63(),
            deviceID)
    }
    

    网关层限流与去重

    type PaymentGateway struct {
        // 使用 Redis 实现请求去重
        requestCache *redis.Client
        
        // 限流器
        rateLimiter *rate.Limiter
    }
    
    func (pg *PaymentGateway) ValidateRequest(req *PaymentRequest) error {
        // 限流检查
        if !pg.rateLimiter.Allow() {
            return errors.New("请求过于频繁")
        }
        
        // 请求去重
        cacheKey := fmt.Sprintf("payment:request:%s", req.RequestID)
        
        // 使用分布式缓存防重
        if pg.requestCache.Exists(cacheKey).Val() > 0 {
            return errors.New("重复请求")
        }
        
        // 缓存请求,设置过期时间
        pg.requestCache.Set(cacheKey, "1", time.Minute*10)
        
        return nil
    }
    

    服务端事务管理

    func (s *PaymentService) ProcessPayment(ctx context.Context, req *PaymentRequest) error {
        // 开启数据库事务
        tx := s.db.Begin()
        
        // 检查是否存在相同的事务
        var existingTxn PaymentTransaction
        if err := tx.Where("request_id = ?", req.RequestID).First(&existingTxn).Error; err == nil {
            tx.Rollback()
            return errors.New("事务已存在")
        }
        
        // 创建新的支付事务
        txn := PaymentTransaction{
            RequestID:  req.RequestID,
            Status:     "Processing",
            CreateTime: time.Now(),
        }
        
        if err := tx.Create(&txn).Error; err != nil {
            tx.Rollback()
            return err
        }
        
        // 执行实际支付
        if err := s.executePayment(tx, req); err != nil {
            tx.Rollback()
            return err
        }
        
        // 提交事务
        return tx.Commit().Error
    }
    

    数据库唯一约束

    -- 创建支付事务表
    CREATE TABLE payment_transactions (
        id BIGINT PRIMARY KEY AUTO_INCREMENT,
        request_id VARCHAR(64) UNIQUE NOT NULL,  -- 唯一约束
        order_id VARCHAR(64) NOT NULL,
        status ENUM('Processing', 'Success', 'Failed') NOT NULL,
        amount DECIMAL(10,2) NOT NULL,
        create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    );
    

    这种多层设计的优势:

    • 在请求的不同阶段提供重复提交保护
    • 降低系统被恶意请求的风险
    • 提供细粒度的请求管理机制

    面试官:如果在高并发场景下,这套机制可能会引入性能瓶颈,你有什么优化建议吗?

    应试者:高并发场景下的性能优化是一个非常关键的话题。我的优化建议包括:

    缓存优化

    • 使用本地进程缓存(如 freecache )
    • 结合分布式缓存( Redis )
    • 对频繁访问的数据进行多级缓存

    异步处理

    func (s *PaymentService) AsyncPaymentProcess(req *PaymentRequest) {
        // 使用消息队列异步处理支付请求
        go func() {
            // 投递到消息队列
            err := s.messageQueue.Publish("payment_topic", req)
            if err != nil {
                // 记录投递失败日志
                log.Error("消息投递失败", err)
            }
        }()
    }
    
    // 消息消费者
    func (s *PaymentService) PaymentConsumer() {
        for {
            msg := s.messageQueue.Consume("payment_topic")
            
            // 并发处理
            go s.ProcessPayment(context.Background(), msg)
        }
    }
    

    细粒度锁

    type ConcurrentPaymentManager struct {
        // 使用分段锁减少锁竞争
        shardedLocks []*sync.RWMutex
    }
    
    func (m *ConcurrentPaymentManager) getLock(key string) *sync.RWMutex {
        // 对 key 进行哈希,选择锁
        return m.shardedLocks[hashCode(key) % len(m.shardedLocks)]
    }
    
    func (m *ConcurrentPaymentManager) ProcessPayment(req *PaymentRequest) {
        lock := m.getLock(req.RequestID)
        lock.Lock()
        defer lock.Unlock()
        
        // 处理支付逻辑
    }
    

    限流与熔断

    type AdaptiveRateLimiter struct {
        // 动态调整的令牌桶
        limit *rate.Limiter
    }
    
    func (rl *AdaptiveRateLimiter) Adjust(currentLoad float64) {
        // 根据系统负载动态调整限流阈值
        if currentLoad > 0.8 {
            rl.limit = rate.NewLimiter(rate.Limit(50), 100)
        } else {
            rl.limit = rate.NewLimiter(rate.Limit(100), 200)
        }
    }
    

    监控与性能分析

    • 实时性能指标监控
    • 链路追踪
    • 动态性能调优

    面试官:最后,对于这样一个支付系统,你有什么架构 level 的思考?

    应试者:支付系统不仅仅是技术实现,更是一个复杂的金融级系统。我的架构思考主要包括:

    安全性

    • 多重风控机制
    • 加密与脱敏
    • 异常交易识别

    可用性

    • 多机房部署
    • 灾备与容灾
    • 平滑降级策略

    合规性

    • 完善的审计追踪
    • 金融合规检查
    • 数据留痕

    可观测性

    • 分布式追踪
    • 实时告警
    • 故障快速定位

    核心是在高性能、高可用、安全性之间找到平衡,构建一个既健壮又灵活的支付系统架构。


    更多 Go 高质量内容试读👇: https://portal.yunosphere.com

    欢迎关注我,经常分享有用的 Go 知识 / 面试 / 创业经历 / 其他编程知识 👇

    • 公众号:GopherYes
    • B 站:YunFuns
    • 知乎、掘金、头条号:YunFun
    • 小红书号:986261983
    32 条回复    2024-12-02 18:29:48 +08:00
    juzuojuzou
        1
    juzuojuzou  
       10 小时 11 分钟前
    假如,用户多次唤起支付宝但未支付,然后在支付宝里面看到多笔待支付单,再从支付宝里面全部支付呢
    YunFun
        2
    YunFun  
    OP
       10 小时 7 分钟前
    @juzuojuzou #1 不是,想尽办法支付是吧,这种用户给我来一打😂
    lry
        3
    lry  
       9 小时 47 分钟前
    用户如果退出再重新进入页面,怎么保证幂等 ID ?
    YunFun
        4
    YunFun  
    OP
       9 小时 32 分钟前 via iPhone
    @lry 理解支付是依赖订单的,幂等在订单维度先拦截,不重新下单的话没有支付的必要条件
    wjfz
        5
    wjfz  
       9 小时 14 分钟前   ❤️ 2
    重复支付问题主要是分两步,第一是生成本系统订单,第二是拿本系统订单去支付。
    1 、生成本系统订单,客户端点完提交按钮就置灰,服务端返回订单号,进入待支付页面。
    2 、在待支付页面选择支付方式,提交支付,点完提交置灰,生成第三方支付平台的订单。跳转第三方平台去支付,支付完成跳回来,完成支付。

    两次置灰能把绝大多数重复请求扼杀在摇篮里。第一步中万一生成了重复的本地订单,因为是带着订单号在本地跳转的,用户全流程都在最后一次(或者初次)生成的订单号流程里,另一个待支付订单只会停留在订单列表。这一步可以加点限流或者自动取消订单操作。
    第二步中,如果用户取消支付,在同一个支付平台重新发起支付,第三方支付的订单号是可以复用的,即使重复点击,也不会生成重复的支付宝订单,就不会出现 1 楼说的问题。
    再扩展一下,假如用户切换了微信支付,在收到支付成功后可以用本系统订单号取消其他支付宝待支付订单。

    再回头看看文章,1 、让客户端生成 RequestID 没有太大意义,因为重复订单都是客户端自己提交上来的。2 、并发大的时候用异步想死,用户一定要迅速看到支付结果,如果异步处理订单,用户就有可能看到订单状态没变重复支付。
    YunFun
        6
    YunFun  
    OP
       9 小时 11 分钟前
    @wjfz 点赞👍 我这里是提供个思路,具体场景还需要不断迭代优化
    lscho
        7
    lscho  
       9 小时 6 分钟前   ❤️ 1
    @juzuojuzou

    同支付通道做支付信息缓存处理,多次唤起支付宝都是同一个支付单。
    不同支付通道(比如先唤起支付宝不支付,再唤起微信),唤起第二个通道的时候同时取消第一个通道的交易。
    YunFun
        8
    YunFun  
    OP
       8 小时 59 分钟前 via iPhone
    @lscho 有用过这个方案
    PendingOni
        9
    PendingOni  
       8 小时 35 分钟前
    重放攻击?
    markgor
        10
    markgor  
       7 小时 51 分钟前
    首先我不懂 GO ,但看了一下大致流程,可能和现实有出入吧。
    1 、首先通过 订单 + 用户 产生一个雪花 ID (全局事务 ID )
    2 、通过雪花 ID 进行控制幂等。
    ---------------如果我没理解错的话,上面这里就忽略了用户需要更改支付途径的场景了,
    比如一开始选择微信支付,后来想换支付宝,此时按你这个设计,用户只能重新下单支付。


    多层幂等性保障:
    网关层限流有必要,去重毫无必要,只是徒增了系统负担,本来根据用户 ID 就能达到限流。至于去重,只需要前端一个 disable 就好了。PS:如果说开控制台修改的话,那其实写脚本刷 token 然后去请求也一样,并且你这里根据客户端层生成 token 进行限流,实际已经没了限流的意义了(因为我可以刷出很多 token )。

    服务端事务管理:
    这里的 request_id 是一开始最上面 TransactionID 吗?如果不是没能理解。
    janus77
        11
    janus77  
       7 小时 45 分钟前
    @juzuojuzou #1 支付成功和订单结算完成应该是两个单独的模块,支付成功后可以再去操作重复的订单,保证结算成功的只有一个订单,如果用户不需要立马在界面上看到你的订单状态,这就可以延后处理;如果需要,那一般只展示一个订单,反正对于用户界面来说他看到的信息都一样,那就是一个订单,后端重复 id 这种东西对用户来说没有意义
    qinxi
        12
    qinxi  
       7 小时 34 分钟前
    一个订单生成一个支付单.
    切换支付方式取消前面的支付单.
    剩下的人家支付系统已经实现好了 用电商端操心?
    coderzhangsan
        13
    coderzhangsan  
       7 小时 33 分钟前
    重复支付幂等性原则确立的目标不应该是业务数据吗?例如业务订单号,请求 id 在业务上没有太大的意义吧。

    而实际支付时,用户多次拉起支付并不支付,因此当前业务订单会产生多笔业务交易流水,这个业务交易流水号也就是三方支付平台对应的商家(开发者)支付单号,所以一般不会用业务订单号作为商家(开发者)支付单号,除非用户每次下单都会创建新的订单。

    根据上面得设计,重复支付应该在业务订单增加中间态校验和去重判断吧。
    markgor
        14
    markgor  
       7 小时 33 分钟前
    其实我觉得很简单的事情
    1 、重复支付的问题,只需要执行退款即可,这个是个兜底策略。
    2 、常规流程如下:
    [前端]->发起预定请求->[后端]
    *后端:生成订单返回单号;(没必要做幂等,就算抢购/优惠卷限购的场景,也是对应的方法进行判断而非订单服务主线程这里进行判断幂等。

    [前端]->发送支付方式->[后端]
    *后端:检查 流水表看是否有支付请求记录,如有则调用对应渠道支付检查,如发现支付成功返回给前端,如发现尚未支付成功则调用渠道的取消订单接口,把之前的支付订单取消掉。然后生成新的支付流水。
    支付流水表:流水 ID + 订单 ID + 渠道 ID

    [前端]->根据后端返回的支付信息,通过流水 ID 拉起支付->[支付平台]
    [前端]->輪詢支付結果,或通過 ws/sse 獲取->[後端]

    異步部分:[支付平台]->消息推送(关闭、支付)->[后端]
    *后端根据流水 ID ,反查订单 ID ,然后通过支付平台接口二次确认支付结果,如结果一致,且之前尚未处理交易信息,则处理交易信息。如之前已经处理完了交易信息,则对本次建议信息进行退款操作,即一開始提到的兜底策略,這裡只需要保證流水 ID 是冪等即可。

    上面應該還有一個是在於處理超時未支付訂單的流程,但這個主要看單量,不同單量不同處理方式,最簡單就直接定時取消,量大的就丟去隊列裡面消費,消費時候檢查支付結果即可。
    layxy
        15
    layxy  
       7 小时 6 分钟前
    @juzuojuzou 只要支付成功一笔,其他的订单应该调用支付渠道关闭订单
    8355
        16
    8355  
       6 小时 32 分钟前
    有 2 个关键点我觉得你没明确说出来,面试官已经有合理引导 - 在架构层面的思考。

    1.前端在同一个客户端需要保证同一时间仅发起一次请求,类似按钮禁用。
    你的回答是幂等,幂等只是你接口需要达到的标准而不是措施或者方案,实际上是需要沟通前端来做,创建订单接口响应或达到超时时间前不能再次发起接口调用,那么理论上产生并发的情况就是多客户端并发下单,这种太极端了正常操作下是不存在的,并发锁拦截即可。这里考的是你是不是只考虑自己的代码而忽略了项目架构。

    2.支付平台层级是可以保证同一个订单号绝对不会出现重复支付的情况。
    那么实际上后端只需要保证不会并发生成订单号即可,增加简单的并发锁就行了。

    在实际的业务中,可能存在非常多的关联业务系统,例如风控系统(可以拦截/取消订单之类的兜底方案和策略)等,实际上是没办法设计你这么复杂的方案去实现这个需求的。
    linxb
        17
    linxb  
       6 小时 7 分钟前
    重复支付的话,给用户退款不就好了
    ffw5b7
        18
    ffw5b7  
       6 小时 6 分钟前 via Android
    有一个问题想咨询下,分布式锁是第三方组件实现的对吧,如果是集群的,存在数据延迟同步怎么解决?
    zsc8917zsc
        19
    zsc8917zsc  
       6 小时 5 分钟前
    闲鱼上的那个快递费,好像是支付宝家的,他家就有重复支付的问题,但是过一段时间后会自动退费,亲身经历。所以...避免啥啊避免,有策略兜底就完事了。
    jonsmith
        20
    jonsmith  
       5 小时 42 分钟前 via Android
    这解决不了重复支付吧,一般在创建支付前先调用第三方支付,查下上笔支付的状态。
    zx9481
        21
    zx9481  
       5 小时 39 分钟前
    不太明白 就算重复下单 生成的订单号也是两个呀
    helone
        22
    helone  
       5 小时 36 分钟前
    重复支付问题无解的,考虑用程序解决还不如加上退款策略,好多大厂都这样的,重复支付没多久就退回来了
    Charlie17Li
        23
    Charlie17Li  
       5 小时 35 分钟前 via iPhone
    @wjfz 大,佬想问下,你提到的第一步里如果重复订单,怎么通过你说的 限流和取消订单 来解决的呢,可以展开讲讲吗
    vishun
        24
    vishun  
       5 小时 33 分钟前
    楼主到底是面试支付宝还是面试普通的商家啊,如果是支付宝的话需要处理这些,如果是普通商家对接第三方支付,第三方支付就已经做好了防止重复支付相关措施,例如唯一的商户订单号什么的,需要你自己处理的不多。
    ffw5b7
        25
    ffw5b7  
       4 小时 59 分钟前 via Android
    @helone 如果的银行转账这种呢
    helone
        26
    helone  
       4 小时 57 分钟前
    @ffw5b7 都支持原路退回的,这个策略本身依赖的是对账功能,能把每笔收入都一一对应到订单上,如果没有对应的订单就要发给人工判断是否原路退回
    bugmakerxs
        27
    bugmakerxs  
       4 小时 55 分钟前
    @juzuojuzou 支付回调发现已经支付,走回滚逻辑通知支付宝给用户退款
    Yanlongli
        28
    Yanlongli  
       4 小时 33 分钟前
    1 、去支付渠道的订单号如果是唯一的,则支付渠道会保证不会重复支付。
    2 、如果发送给支付渠道的订单号不是唯一的,则创建新订单时先通知支付渠道取消之前的订单。
    lqu3j
        29
    lqu3j  
       4 小时 26 分钟前
    多支付渠道,且支付渠道没有取消支付会话的情况下怎么处理? 这种场景下似乎无解的,只能乖乖标记订单,然后退款
    mark2025
        30
    mark2025  
       4 小时 5 分钟前
    幂等标识 是什么东东?
    pkoukk
        31
    pkoukk  
       3 小时 17 分钟前
    我可以理解避免重复下单,但是啥情况需要这么高级别的避免重复支付?
    做个分布式锁拦一下,然后做个定时机制或者事件机制,对一个订单存在多个支付单的情况,逐个退款就完事了
    把支付搞这么重,感觉不合适
    vipfts
        32
    vipfts  
       3 小时 13 分钟前
    把 app 和网站做得卡卡地,让用户无法在极短时间内两次支付。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   3145 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 31ms · UTC 13:43 · PVG 21:43 · LAX 05:43 · JFK 08:43
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.