点评--day04--集群下的一人一单并发问题

7 阅读6分钟

集群下的一人一单并发问题学习文档

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 的 WATCHMULTI 命令

为了避免多个线程并发查询和创建订单,可以利用 Redis 的 WATCHMULTI 命令来保证事务性操作。

1. 事务性的保证
  • WATCH 用于监听某个键的值,当该键的值发生变化时,事务会被中止。
  • MULTIEXEC 用于包裹一组 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 通过 ReentrantLockReadWriteLockSemaphore 等方式提供了分布式锁机制,使得多线程访问共享资源时不会发生冲突。

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 的分布式锁和队列,你可以确保高并发秒杀时的资源竞争不会导致数据一致性问题,从而保证系统稳定性和用户体验。