引言

上周五快下班的时候,运营突然在群里@我:"怎么用户支付了两次?客户都投诉到总经理那里了!"这可把我吓出一身冷汗,赶紧登录后台查看订单记录。

好家伙,果然发现同一个订单号被扣款了两次!我心里一沉,这可是线上生产环境的重大Bug啊,涉及到钱的问题那可是天大的事!

----->>>> 重复扣款问题分析与解决

1. 问题排查

我立即开始排查问题:

首先打开日志(打印日志的方法有几种,这里不在赘述),仔细查看服务端的请求记录。果然在同一秒内,收到了两次完全相同的支付请求,而且都成功执行了!

这下问题清晰了,原来是前端在用户点击支付按钮的时候,因为接口响应慢,用户以为没点到,又快速点击了一次。虽然前端做了按钮置灰,但两次点击的间隔太短,第二次请求还是发出去了。

更关键的是,我的后端接口居然没有做幂等性校验,直接处理了两次相同的请求!

2. 什么是幂等性?

排查完问题后,我意识到必须好好理解一下幂等性这个概念了。

简单来说,幂等(idempotency)本来是一个数学概念,在软件开发中的意思就是:不论执行多少次相同的请求,产生的效果和返回的结果都和执行单次请求一样

举个例子,就像数学里的 f(n) = 1^n,无论 n 为多少,结果永远是 1。

针对数据库操作来说:

  • insert操作要保证不插入重复的数据

  • update操作要保证多次相同请求数据依然正确

不保证幂等会有什么后果?

就像我遇到的这次生产事故,用户重复点击支付按钮,结果被扣了两次钱。这要是在大型电商平台,涉及的金额可能成千上万,后果不堪设想!

所以,接口幂等性问题绝对不能忽视,尤其是在涉及到钱、库存、积分这些关键业务的时候。

3. 导致幂等性问题的常见原因

根据我的经验,导致接口重复请求的原因通常有这几种:

  • 网络波动:请求超时后自动重试

  • 用户重复操作:像我这次遇到的,用户以为没点到,多次点击

  • 消息重复消费:MQ消息被重复投递

  • 接口响应慢:前端以为请求失败,发起重试

而且,保证幂等性不能只靠前端! 前端可以做按钮置灰,但网络波动、消息重复消费这些场景,前端根本控制不了,所以后端必须也要做幂等性保证。

4. 如何保证接口幂等性?

吃了这次亏之后,我专门研究了一下业界常用的幂等性保证方案,总结出以下几种方法:

4.1 悲观锁

悲观锁的思想就是"我就是觉得会有并发问题,所以先锁住再说"。

在 Java 中,可以使用 synchronizedReentrantLock 来实现。但是,JDK 自带的锁是本地锁,在分布式环境下就不管用了。

// 单机环境下的悲观锁示例
synchronized(this) {
    // 检查订单状态
    // 执行支付逻辑
}

除了 Java 的锁,数据库本身也提供了排他锁(X 锁):

SELECT ... FOR UPDATE

不过要注意,排他锁只能在支持事务的存储引擎(如 InnoDB)中使用,而且必须在有索引的字段上使用,否则会锁住整个表,性能会严重下降。

悲观锁的问题

  • 高并发下锁竞争激烈,会造成大量线程阻塞

  • 可能存在死锁风险

  • 性能开销较大

4.2 乐观锁

乐观锁的思想正好相反:"我觉得并发冲突不会经常发生,只有在更新的时候才检查一下"。

最常见的实现方式是版本号机制:在表中增加一个 version 字段,每次更新时检查版本号是否一致。

-- 更新数据,同时版本号+1,并检查版本号是否匹配
UPDATE goods 
SET price = price + 100, version = version + 1 
WHERE id = 1 AND version = 1;
​
-- 由于 version 已经变为 2,下面这条 SQL 执行无效
UPDATE goods 
SET price = price + 100, version = version + 1 
WHERE id = 1 AND version = 1;

乐观锁的优缺点

  • ✅ 没有锁竞争,性能比悲观锁好

  • ✅ 不会有死锁问题

  • ❌ 如果冲突频繁(写操作多),会频繁失败重试,反而影响性能

  • ❌ 只适用于更新数据的场景

4.3 唯一索引

这个方案比较简单粗暴,直接在数据库表中加唯一索引,保证数据的唯一性。

CREATE TABLE t_order(
    id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键",
    `code` VARCHAR(200) NOT NULL COMMENT "订单流水号",
    `customer_id` INT UNSIGNED COMMENT "会员id",
    `amount` DECIMAL(10,2) UNSIGNED NOT NULL COMMENT "总金额",
    -- 省略其他字段
    -- 订单流水号唯一
    UNIQUE unq_code(`code`)
) COMMENT="订单表";

如果有重复数据插入,数据库会直接抛出异常,程序捕获异常就知道是重复请求了。

注意:不要依靠唯一索引来保证接口幂等,但建议使用唯一索引作为兜底,避免产生脏数据。毕竟防不胜防嘛!

4.4 去重表

去重表其实也是唯一索引的一种应用,只不过单独建一张表来记录已处理的请求。

CREATE TABLE deduplication_table (
    id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键",
    processed_code VARCHAR(200) NOT NULL COMMENT "已处理的订单流水号",
    -- 省略其他字段
    UNIQUE unq_processed_code(processed_code)
) COMMENT="去重表";

当客户端发出请求时,先往去重表里插入一条记录:

  • 插入成功 → 第一次请求,执行业务逻辑

  • 插入失败 → 重复请求,直接返回

4.5 分布式锁 (推荐)

对于我们这种分布式系统来说,最常用的还是分布式锁

一般基于 Redis 或 ZooKeeper 实现,Redis 用得更多一些。

以 Redisson 为例,实现幂等的代码如下:

// 使用订单号作为唯一标识
String orderId = "order123";
​
// 1. 根据订单号生成分布式锁对象
RLock lock = redisson.getLock("lock:" + orderId);
​
try {
    // 2. 尝试获取锁(Redisson 自带 Watch Dog 自动续期机制)
    if (lock.tryLock()) {
        // 3. 成功获取锁,说明是第一次请求,执行业务逻辑
        // 检查订单状态
        // 执行支付逻辑
        ...
    } else {
        // 获取锁失败,说明请求正在被处理或已处理
        return "请勿重复提交";
    }
} finally {
    // 4. 释放锁
    lock.unlock();
}

为什么推荐分布式锁?

  • ✅ 适用于分布式环境

  • ✅ 实现相对简单

  • ✅ 性能较好(Redis 内存操作)

  • ✅ 可以设置锁的过期时间,避免死锁

这个方案在实际项目中用得最多,我后来也是用分布式锁解决了那次重复支付的问题。

4.6 Token 机制

Token 机制的核心思想是:为每次操作生成一个唯一的凭证

需要两次请求才能完成一次业务操作:

第一步:获取 Token

客户端请求 → 服务端生成唯一 Token → 保存到 Redis(设置过期时间)→ 返回给客户端

第二步:提交业务请求

客户端携带 Token 请求 → 服务端验证并删除 Token → 执行业务逻辑

这里有个问题:是先删除 Token 还是先执行业务逻辑?

  • 先执行业务逻辑:Token 还存在时,客户端可能再次携带 Token 发起请求

  • 先删除 Token:如果业务逻辑执行超时,客户端重试时 Token 已经没了

我的建议是先删除 Token,如果业务执行异常,让客户端重新获取 Token 再请求。虽然有点麻烦,但只有极少数请求会遇到这种情况,总体来说更安全。

5. 我的解决方案

回到我那次生产事故,最终我是这样解决的:

  1. 前端层面:优化按钮置灰逻辑,确保请求发出后立即禁用按钮

  2. 后端层面:使用 Redis 分布式锁 + 订单状态判断 的组合方案

  3. 兜底方案:在订单流水号字段上加唯一索引

代码大致如下:

@Transactional(rollbackFor = Exception.class)
public void processPayment(String orderId) {
    RLock lock = redisson.getLock("payment:lock:" + orderId);
    
    try {
        // 尝试获取锁,最多等待10秒,锁30秒后自动释放
        if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
            // 查询订单状态
            Order order = orderMapper.selectById(orderId);
            
            // 判断订单状态,已支付则直接返回
            if (order.getStatus() == OrderStatus.PAID) {
                log.warn("订单已支付,请勿重复提交:{}", orderId);
                return;
            }
            
            // 执行支付逻辑
            // ...
            
            // 更新订单状态
            order.setStatus(OrderStatus.PAID);
            orderMapper.updateById(order);
        } else {
            throw new BusinessException("系统繁忙,请稍后重试");
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BusinessException("支付请求被中断");
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

6. 总结

经过这次教训,我深刻体会到:在涉及到钱、库存、积分等关键业务时,接口幂等性必须格外注意!

不同场景可以选择不同的方案:

  • 插入场景:唯一索引、去重表

  • 更新场景:乐观锁、悲观锁

  • 分布式系统:分布式锁、Token 机制(推荐)

最后建议:无论采用哪种方案,都要加上唯一索引作为最后的兜底,防止脏数据产生。


参考