集群下的一人一单并发问题学习文档
1. 什么是一人一单
一人一单的需求非常简单:
同一个用户对同一秒杀商品只能下单一次。
这一需求在 单机环境 下通过简单的数据库查询来实现,但在 分布式环境 下,多个服务器同时运行,导致多个线程或多个实例可能并发访问同一资源,这时就需要一种机制来保证同一用户不会重复下单。
1.1 问题场景
假设在秒杀过程中:
- 用户 A 在同一时间发出两个秒杀请求
- 两个请求会落到不同的服务器(或 JVM)
- 由于并发问题,两个请求都会执行
创建订单的操作,导致同一个用户购买了两次同样的商品
1.2 为什么分布式环境会有问题
- 在集群环境下,多个实例并行工作,如果每个实例都有自己的本地锁,就无法确保不同实例间的数据一致性。
- 比如线程 A 在实例 1 中执行查询操作,线程 B 在实例 2 中也查询了相同的数据,两者都判断该用户未下单,结果导致重复下单。
2. 分布式环境中的并发问题
在集群环境下,常见的一人一单并发问题表现为以下几种情况:
2.1 多线程竞争
多个请求几乎同时查询数据库,如果没有任何控制,多个请求可能会同时查询到相同的用户信息,并且都通过了判断,最终执行订单创建操作,造成重复下单。
2.2 数据一致性问题
多个实例在并发执行过程中,可能会出现多个请求分别在不同的实例中判断“是否下过单”的操作,造成数据不一致。
3. 集群下如何解决并发问题
为了避免集群中多个请求同时插入同一条数据(重复下单),需要采用以下几种方式来保证数据的一致性和锁的安全。
3.1 基于 Redis 的分布式锁
在 集群环境 中,单机锁(如 synchronized)不再适用,因为它只能保证同一 JVM 内的并发控制,无法跨多个节点。此时需要 Redis 分布式锁 来确保在同一时刻只有一个请求能够查询和插入订单。
1. 加锁逻辑:
- 在处理一人一单时,为了防止多个请求同时判断用户是否下单,所有请求都需要加锁。
- 通过 Redis 的
SETNX命令来为每个用户加锁,锁的 key 可以设计成lock:userId,锁的 value 可以使用用户的 ID 或者 UUID。
2. 加锁与解锁:
- 如果一个请求成功获取锁,就继续进行订单创建操作。
- 其他请求因为锁的存在,无法再进行下单操作,避免重复下单。
- 订单创建完成后释放锁。
代码示例:
String lockKey = "seckill:lock:user:" + userId;
Boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS); // 设置锁并设置超时
if (lock != null && lock) {
try {
// 执行创建订单逻辑
} finally {
redisTemplate.delete(lockKey); // 解锁
}
} else {
// 锁获取失败,可能是同一用户的多个请求
return Result.fail("请勿重复提交!");
}
3. 分布式锁的超时问题:
- 死锁:如果持有锁的请求没有及时释放锁(如程序崩溃或卡住),其他请求将无法获取锁。
- 防止死锁:设置合理的锁超时时间,确保在指定时间后锁会被自动释放。可以通过 Redis 的
EX命令来设置锁的超时时间,避免死锁。
3.2 使用 Redis 的 WATCH 和 MULTI 命令
为了避免多个线程并发查询和创建订单,可以利用 Redis 的 WATCH 和 MULTI 命令来保证事务性操作。
1. 事务性的保证:
WATCH用于监听某个键的值,当该键的值发生变化时,事务会被中止。MULTI和EXEC用于包裹一组 Redis 操作,将它们打包成原子操作。
String orderKey = "order:user:" + userId;
redisTemplate.watch(orderKey);
redisTemplate.multi();
redisTemplate.opsForValue().set(orderKey, "1"); // 标记订单已创建
redisTemplate.exec(); // 提交操作,确保原子性
这种方式通过乐观锁机制,确保只有一个线程能够成功执行订单的创建操作。
3.3 利用 Redis 队列控制秒杀请求
为了限制并发请求对库存造成的压力,可以利用 Redis 队列控制请求的顺序,确保高并发时请求能有序进行。
1. 使用阻塞队列:
- 将秒杀请求按照顺序加入 Redis 队列,通过
BRPOP等命令从队列中取出请求,处理完一个请求后再处理下一个,避免多个请求同时访问库存。
redisTemplate.opsForList().leftPush("seckill:queue", requestId);
String requestId = redisTemplate.opsForList().rightPop("seckill:queue", 10, TimeUnit.SECONDS);
2. 优势:
- 队列保证了请求有序地处理,避免了多个请求同时操作库存,解决了竞争问题。
- 队列可以控制并发流量,避免瞬时大量请求压垮系统。
3.4 利用 Redisson 提供的分布式锁
如果你使用的是 Redisson,它提供了更加完善的分布式锁功能。Redisson 通过 ReentrantLock、ReadWriteLock 和 Semaphore 等方式提供了分布式锁机制,使得多线程访问共享资源时不会发生冲突。
RLock lock = redissonClient.getLock("lock:user:" + userId);
lock.lock(10, TimeUnit.SECONDS);
try {
// 执行下单操作
} finally {
lock.unlock();
}
Redisson 的好处在于,它不仅提供了分布式锁,还提供了更多的分布式对象,简化了开发过程,并且可以方便地进行锁的管理和监控。
4. 集群下分布式锁的常见问题与优化
4.1 Redis 锁的误删除问题
- 解决方法:在解锁时要检查锁是否被当前线程持有,以防止误删他人的锁。
4.2 Redis 锁的性能问题
- 解决方法:合理设置锁的过期时间,避免死锁,并且避免对过多的资源加锁。
4.3 多个线程竞争锁时的性能问题
- 解决方法:降低锁的粒度,确保锁的使用仅限于必要的操作。
5. 总结
- Redis 分布式锁:保证在集群环境下只有一个实例能够执行关键操作,避免并发时出现重复下单的问题。
- WATCH/MULTI:利用 Redis 的事务性操作,保证在分布式环境中用户只能下单一次。
- 队列控制流量:使用 Redis 队列来排队秒杀请求,避免请求的并发冲突。
- Redisson 分布式锁:使用 Redisson 提供的分布式锁,更加简化了锁的管理与使用。
通过 Redis 的分布式锁和队列,你可以确保高并发秒杀时的资源竞争不会导致数据一致性问题,从而保证系统稳定性和用户体验。