3秒内订单重复提交,后台如何防护?

彩虹网

这不是加个锁就完事的问题。

很多人的第一反应是"用 Redis 分布式锁"或"加个唯一索引"。但真实的生产环境远比这复杂:

3秒内的重复请求是怎么来的

用户快速点击5次支付按钮,你以为服务器会收到5次请求?

不一定。

前端防抖不等于万无一失

很多前端会对按钮做防抖(debounce),用户快速点5次,实际只发出1次请求。但防抖不是浏览器的默认行为,完全取决于前端代码有没有做。如果前端没做防抖,或者用户绕过页面直接调接口,5次点击就是5次 HTTP 请求,一个都不会少。

网络超时导致的客户端重试

客户端发出 HTTP 请求后,服务器处理完了,但 HTTP 响应在网络传输中丢了。客户端因为没收到响应,认为请求失败,触发重试——可能是 HTTP 客户端库的自动重试(比如 OkHttp、Axios 默认的 retry 机制),也可能是用户看到"请求超时"后手动刷新页面。

这时候服务器实际上已经成功处理了第一次请求,但又收到了第二次相同的请求。两次请求间隔可能就在几秒之内,你的 Spring Controller 会把它们当成两个独立的请求来处理。

负载均衡的粘性会话失效

用户第一次请求打到了服务器A,第二次请求打到了服务器B。如果你的防重机制是基于"进程内存"的,这两个请求都会通过。

Nginx 默认的负载均衡策略是轮询,不保证同一个用户的请求打到同一台机器。除非你配置了 ip_hash 或 sticky session。

所以,3秒内的重复请求,可能是:

后台必须假设:前端的防重机制不可靠。

分布式锁(能用,但性能垃圾)

最直观的方案:用 Redis 加锁,同一个用户同一个订单,只允许一个请求通过。

public void createOrder(Long userId, Long productId) {
    String lockKey = "order_lock:" + userId + ":" + productId;
    
    if (redisLock.tryLock(lockKey, 3, TimeUnit.SECONDS)) {
        try {
            // 创建订单
            orderService.create(userId, productId);
        } finally {
            redisLock.unlock(lockKey);
        }
    } else {
        throw new BizException("请勿重复提交");
    }
}

看起来很完美。但有个致命问题:性能。

Redis 分布式锁是串行执行的

假设创建订单的逻辑需要 500ms(查库、扣库存、写订单表、发消息)。那么同一个用户,1秒内最多只能创建2个订单。

如果订单逻辑更复杂,比如需要调用支付接口、库存接口、优惠券接口,耗时可能达到1-2秒。那QPS直接拉胯。

更大的问题是架构上的

分布式锁意味着你的订单创建逻辑变成了串行。就算用 Redis Cluster 把锁分散到不同节点,同一个用户的同一个商品,锁还是串行的。业务逻辑跑 500ms,这 500ms 里第二个请求只能干等着。

对于大多数业务来说,订单防重根本不需要锁。锁是用来保证"互斥访问共享资源"的,但防重的本质是"拒绝重复",不是"排队等待"。用锁来防重,就像用大炮打蚊子——能打中,但没必要。

分布式锁真正适合的场景是库存扣减、秒杀这类必须串行操作的业务。普通订单创建,有更轻量的方案。

数据库唯一索引(简单粗暴,但有坑)

在订单表上加一个唯一索引,比如 user_id + product_id + timestamp。重复请求插入时会触发唯一约束冲突,直接拒绝。

CREATE TABLE `order` (
    `id` bigint NOT NULL AUTO_INCREMENT,
    `user_id` bigint NOT NULL,
    `product_id` bigint NOT NULL,
    `created_at` bigint NOT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_user_product_time` (`user_id`, `product_id`, `created_at`)
) ENGINE=InnoDB;

业务代码:

public void createOrder(Long userId, Long productId) {
    Order order = new Order();
    order.setUserId(userId);
    order.setProductId(productId);
    order.setCreatedAt(System.currentTimeMillis());
    
    try {
        orderMapper.insert(order);
    } catch (DuplicateKeyException e) {
        throw new BizException("请勿重复提交");
    }
}

这个方案简单,不依赖 Redis,性能也好。唯一索引的冲突检测是在数据库层面,几乎没有额外开销。

但有几个坑要注意。

时间戳精度问题

Java 的 System.currentTimeMillis() 精度是毫秒。如果两个请求在同一毫秒内到达,时间戳相同,唯一索引生效,看起来没问题。

但反过来想:同一个用户如果在短时间内合法地买两次同一个商品(比如给不同地址各下一单),两个请求恰好在同一毫秒到达,唯一索引就会把第二个合法请求也误拒了。

更靠谱的做法是让前端生成一个请求唯一标识,或者后端用雪花ID:

order.setRequestId(UUID.randomUUID().toString());

把 request_id 加到唯一索引里,比用时间戳靠谱得多。

并发 insert 可能死锁

MySQL 在插入数据时,会先在唯一索引上加 "插入意向锁"(Insert Intention Lock)。如果两个事务同时插入相同的唯一索引值,会发生死锁。

比如:

时间线:
T1: begin; insert order (user_id=1, product_id=100, request_id='abc')
T2: begin; insert order (user_id=1, product_id=100, request_id='abc')
T1: commit;
T2: Deadlock found when trying to get lock; try restarting transaction

MySQL 会检测到死锁,回滚其中一个事务。但这会导致业务代码收到 DeadlockLoserDataAccessException。如果你的代码没有捕获这个异常,用户会看到"系统错误"。

所以除了捕获 DuplicateKeyException,死锁异常也得一起兜住:

try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    throw new BizException("请勿重复提交");
} catch (DeadlockLoserDataAccessException e) {
    throw new BizException("请勿重复提交");
}

唯一索引遇到 NULL 就废了

如果你的唯一索引字段包含 NULL 值,唯一约束就失效了。

比如你的唯一索引是 user_id + product_id + coupon_id,但 coupon_id 可能为空(用户没用优惠券)。那么两个都是 NULL 的订单,可以同时插入。

这是 MySQL 的特性:NULL 不参与唯一性检查。所以唯一索引里不要包含可空字段,如果业务上确实可能为空,用 0 代替 NULL。

Token 机制(看起来完美,但有隐藏成本)

Token 机制的思路:用户打开下单页面时,后台生成一个唯一的 Token 存到 Redis 返回给前端。用户提交订单时带上这个 Token。后台校验 Token 是否存在,存在则删除并继续执行,不存在则拒绝。

// 1. 生成 Token
public String generateOrderToken(Long userId) {
    String token = UUID.randomUUID().toString();
    String key = "order_token:" + userId + ":" + token;
    redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
    return token;
}
// 2. 提交订单时校验 Token
public void createOrder(Long userId, String token, Long productId) {
    String key = "order_token:" + userId + ":" + token;
    
    // 使用 Lua 脚本保证原子性:检查存在 + 删除
    String luaScript = 
        "if redis.call('get', KEYS[1]) then " +
        "    redis.call('del', KEYS[1]) " +
        "    return 1 " +
        "else " +
        "    return 0 " +
        "end";
    
    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(luaScript, Long.class),
        Collections.singletonList(key)
    );
    
    if (result == 0) {
        throw new BizException("请勿重复提交");
    }
    
    // 创建订单
    orderService.create(userId, productId);
}

这个方案性能不错,Redis 的 get/del 操作很快,不需要加锁,不会阻塞其他请求,而且支持分布式,多台服务器共享 Token。

但有两个隐藏的成本。

前端要配合改造

前端必须先调用 generateOrderToken 接口获取 Token,然后在提交订单时带上。这增加了一次网络请求。

对于普通的表单提交,这个成本可以接受。但对于高并发场景(比如秒杀),多一次请求就是多一倍的 QPS。

Redis 挂了就全完了

Token 存在 Redis 里,如果 Redis 挂了或雪崩了(大量 Key 同时过期),整个订单系统就挂了。

有人说:"可以降级,Redis 挂了就不校验 Token。"

那万一 Redis 挂的时候,正好有用户重复提交订单呢?降级就意味着放弃防重。

所以 Token 机制比较适合表单提交、实名认证这类需要前端配合、且并发量不高的场景。

状态机 + 乐观锁

核心思路:订单有多个状态,状态流转是单向的。利用状态流转的原子性来防重。

订单状态:

状态流转规则:

防重的关键:用乐观锁保证状态流转的原子性

public void payOrder(Long orderId) {
    // 1. 先用乐观锁抢占状态,只有一个请求能成功
    int rows = orderMapper.updateStatus(orderId, 0, 1);
    if (rows == 0) {
        // 状态已被其他请求修改,说明重复提交了
        throw new BizException("请勿重复提交");
    }
    
    // 2. 抢占成功,再调用支付接口
    Order order = orderMapper.selectById(orderId);
    try {
        payService.pay(order.getUserId(), order.getAmount());
    } catch (Exception e) {
        // 支付失败,回滚状态
        orderMapper.updateStatus(orderId, 1, 0);
        throw new BizException("支付失败,请重试");
    }
}

SQL:

UPDATE `order` 
SET status = 1 
WHERE id = #{orderId} AND status = 0

这个 UPDATE 语句的精髓:只有当前状态是 0 时,才能更新为 1

如果两个请求同时执行,第一个请求会成功(返回 rows=1),第二个请求会失败(返回 rows=0,因为状态已经是 1 了)。关键是先用乐观锁抢占状态,再去做支付这种有副作用的操作。反过来的话,支付成功了但状态更新失败,就是重复扣款。

这个方案不依赖 Redis,不需要分布式锁,性能好,就是一条普通的 UPDATE 语句。代码简单,逻辑清晰,天然支持幂等性(多次执行结果一致)。

唯一的限制:必须有明确的状态流转。如果你的业务逻辑没有状态的概念(比如日志记录、数据同步),这个方案就不适用了。但对于订单、支付、退款这类业务,状态机是标配。

Redis 主从切换导致的锁丢失

这个问题很多人忽略了。

Redis 的主从架构里,主节点挂了,从节点会自动升级为主节点。但 Redis 的主从同步是异步的,存在短暂的数据丢失窗口。

具体是这么回事:客户端在主节点加锁成功,主节点还没来得及同步到从节点就挂了,从节点升级为新的主节点,但上面没有锁数据。另一个客户端重试,能再次加锁成功——两个客户端同时持有锁,防重失效。

这个问题在美团的技术博客里有详细记录。

Redis 提供了两个配置参数来缓解脑裂:

min-slaves-to-write 1
min-slaves-max-lag 10

意思是:主库必须至少有 1 个从库在 10 秒内完成同步,否则拒绝写入。

但这只能降低概率,无法彻底解决。因为配置得太严格会影响可用性(主从网络抖动时,主库会拒绝所有写入)。

所以生产环境不要只依赖一种防重机制。 Redis 锁挡住 99% 的重复请求,数据库唯一索引挡住 Redis 锁失效的情况,状态机挡住前两层都失效的情况。三层防护,才能把重复提交的概率压到极低。

不同场景怎么选

从性能角度粗略排个序:状态机+乐观锁 ≈ Token 机制 > 数据库唯一索引 > 分布式锁。分布式锁因为串行执行,QPS 大概会腰斩甚至更低。其他三种方案对性能的影响都不大。数据库唯一索引偶尔会遇到死锁,但概率很低。

普通的订单、支付、退款:用状态机 + 乐观锁。

性能好,代码简单,不依赖 Redis,天然支持幂等。

UPDATE `order` SET status = 1 WHERE id = ? AND status = 0

一条 SQL 搞定,没有任何花里胡哨的东西。

秒杀、抢票、库存扣减:用 Redis 分布式锁。

库存有限,必须串行扣减,否则会超卖。

if (redisLock.tryLock("stock:" + productId)) {
    try {
        stock = getStock(productId);
        if (stock > 0) {
            stock--;
            saveStock(productId, stock);
        }
    } finally {
        redisLock.unlock("stock:" + productId);
    }
}

秒杀场景本来就是低 QPS、高并发,Redis 锁的性能开销可以接受。

表单提交、实名认证、绑卡:用 Token 机制。

这些场景天然需要前端配合(用户要先打开页面,再提交表单),多一次获取 Token 的请求不影响体验。而且并发量不高,Token 机制的 Redis 依赖不是问题。

日志记录、数据同步、消息发送:用唯一 ID。

这些场景没有状态流转的概念,也不适合加锁。最简单的办法是给每个请求生成一个唯一 ID(比如雪花 ID 或 UUID),存到数据库的唯一索引里。

LogRecord log = new LogRecord();
log.setRequestId(UUID.randomUUID().toString());
log.setContent("xxx");
logMapper.insert(log);

重复请求会触发唯一约束冲突,直接忽略即可。

高可用要求极高的核心业务:多层防护。

Redis 锁 + 数据库唯一索引 + 状态机,三层兜底。虽然复杂,但核心链路上不能赌。

防重机制不是越复杂越好,而是越适合业务场景越好。能用状态机解决的,就别引入 Redis。能用数据库解决的,就别引入分布式锁。技术选型的本质是权衡,性能、复杂度、可维护性、成本,不可能全都要。

免责声明:由于无法甄别是否为投稿用户创作以及文章的准确性,本站尊重并保护知识产权,根据《信息网络传播权保护条例》,如我们转载的作品侵犯了您的权利,请您通知我们,请将本侵权页面网址发送邮件到qingge@88.com,深感抱歉,我们会做删除处理。