这不是加个锁就完事的问题。
很多人的第一反应是"用 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。能用数据库解决的,就别引入分布式锁。技术选型的本质是权衡,性能、复杂度、可维护性、成本,不可能全都要。
标签: