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