一、全局唯一 ID
1.1 使用场景
在网上的一些店铺中,每个店铺都可以发布优惠券,如下图:
当用户抢购时,就会生成订单保存到对应的订单表中,而订单表如果使用数据库自增 ID 就会存在一些问题:id 的规律性太明显、受单表数据量的限制。
1.2 全局 ID 生成器
全局 ID 生成器,是一种在分布式系统下用来生成全局唯一 ID 的工具,一般要满足下列特性:
为了增加 ID 的安全性,我们可以不直接使用 Redis 自增的数值,而是拼接一些其它信息,如下图:
ID 的组成部分为:符号位+时间戳+序列号 构成
符号位:1bit,永远为 0
时间戳:31bit,以秒为单位,可以使用 69 年
序列号:32bit,秒内的计数器,支持每秒产生 2^32 个不同 ID
1.3 代码实现
接下来我们使用代码来实现一个全局 ID 生成器,代码如下:
@Component
public class RedisIdWorker {
@Resource
StringRedisTemplate stringRedisTemplate;
// 初始时间的时间戳
private static final long BEGIN_TIMESTAMP = 1704067200l;
// 序列号的位数
private static final int COUNT_BITS = 32;
// 使用前缀区分不同的业务
public long nextId(String keyPrefix){
// 1、生成时间戳,单位为秒。
// 时间戳是指某个时间点相对于一个固定的起始点所经历的秒数或毫秒数(默认的固定起始点为格林威治时间 1970年1月1日 00:00:00)
// 获取当前的时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
// 当前时间戳 - 初始时间戳 = 生成的时间戳
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2、生成序列号
// 2.1 获取当前的日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2 自增长
long count = stringRedisTemplate.opsForValue().increment("incr:" + keyPrefix + ":"+date);
// 3、拼接并返回,使用或运算进行拼接
return timestamp << COUNT_BITS | count;
}
// 生成一个初始时间
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2024, 1, 1, 0, 0, 0);
// 设置时区
long second = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(second);
}
}
测试代码如下:
@Test
void testRedisWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () ->{
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id="+id);
}
latch.countDown();
};
long beginTime = System.currentTimeMillis();
for (int i = 0; i <300 ; i++) {
service.submit(task);
}
latch.await();
long endTime = System.currentTimeMillis();
System.out.println("time="+(endTime-beginTime));
}
1.4 小结
常用的全局唯一 ID 生成策略包括:UUID、Redis 自增、雪花算法、数据库自增。
Redis 自增 ID 策略:每天一个 key 方便统计订单量,ID 构造是时间戳 + 计数器。
二、实现优惠券秒杀下单
2.1 使用场景
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购,如下图:
用户可以在店铺页面中抢购这些优惠券,如下图:
首先使用 postman 调用 seckill 方法,这个方法的详情如下,就是先在数据库 tb_voucher 优惠劵表中插入一条优惠券的数据,然后在在 tb_seckill_voucher 优惠券秒杀表里面插入一条秒杀券的数据。
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
}
tb_voucher:优惠券的基本信息,优惠金额、使用规则等。
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息。
2.2 功能分析
实现优惠券秒杀的下单功能,下单时需要判断以下两点,整体的流程图如下图所示:
1、秒杀是否开始或结束,如果尚未开始或已经结束则无法下单;
2、库存是否充足,不足则无法下单
2.3 代码实现
秒杀功能的 controller 层代码如下所示:
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
IVoucherOrderService voucherService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherService.seckillVoucher(voucherId);
}
}
service 层的代码如下所示:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
ISeckillVoucherService seckillVoucherService;
@Resource
RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1、查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2、判断秒杀是否开始
if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3、判断秒杀是否结束
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4、判断库存是否充足
if(seckillVoucher.getStock() < 1){
return Result.fail("库存不足");
}
// 5、扣减库存
boolean result = seckillVoucherService.update().setSql("stock = stock-1")
.eq("voucher_id", voucherId).update();
if(!result){
return Result.fail("库存不足");
}
// 6、创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户 ID
UserDTO user = UserHolder.getUser();
voucherOrder.setUserId(user.getId());
// 6.3 订单 ID
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7、返回订单id
return Result.ok(orderId);
}
}
测试效果也是没有问题的,如下图:
三、超卖问题
3.1 现象演示
上面我们模拟的是一个用户的抢购功能,接下来我们使用 jemter,模拟 200 个用户同时抢购优惠券的场景(别忘了配置 header 的 token),先把数据库的数据恢复成原来的样子,如下,此时数据库中优惠券的数量变成了 -8,出现了线程安全的问题。
订单表出现了 108 条数据,说明我们的优惠券卖超了,多卖了八张,如下图:
3.2 问题分析
3.2.1 理想情况
线程 1 首先查询库存,判断库存是否大于 0,发现还剩一个,此时进行扣减,此时库存变为 0,而线程 2 此时查询库存,发现库存等于 0,不再进行扣减操作,如下图:
3.2.1 实际情况
在高并发的场景下,我们无法控制线程的执行顺序,线程与线程之间很有可能交叉执行,线程 1 执行查询操作,查到库存为 1,就在此时线程 2 也来了,就在线程 1 尚未扣减库存之前,线程 2 也查到了库存为 1,此时线程 1 进行库存扣减,此时库存变为 0,线程 2 还会拿剩余库存 1 进行判断,但此时库存已经变为 0 了,继续扣减库存,最终库存就变成 -1 了。如下图:
3.3 解决方式
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁,悲观锁就是添加 synchronized 或者 Lock 关键字即可。也可以使用乐观锁来解决这个问题,乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种,分别为版本号法和 CAS 法。
3.3.1 乐观锁之版本号法
说白了就是给数据库表添加一个 version 字段,但凡是更新操作,都将 version 字段的值加 1,下面来简单描述下。
假设一开始库存还剩 1 个,version 版本也为 1,此时线程 1 和线程 2 查询数据库的库存和版本号,发现都是 1,可以进行扣减库存的操作,如下图:
此时线程 1 先执行更新操作,对库存和版本号都进行了更新,此时库存变成 0 ,version 变成了 2,此时线程 2 开始执行更新操作,现在表里面的 version 变成 2,但是它执行更新操作时的条件 version = 1,所以此时线程 2 的更新操作是无法完成的。
通过版本号的方式就解决了线程安全问题。
3.3.2 乐观锁之 CAS 法
这种方式就比较简单了,不用额外的给数据库表添加字段了,只需要使用原有的数据库表里面的字段即可。
假设此时库存 stock =1,线程 1 和线程 2 查询库存发现都还剩 1 个,都可以进行扣减操作,此时线程 1 先执行,判断的条件是当前的库存是否等于 1。
等到线程 1 执行完成后,此时库存 stock =0,此时线程 2 开始执行扣减操作,他的 where 条件里面 stock =1,这个条件是无法更新成功的,因为库存被线程 1 变为了 0。
通过 CAS 的方式就解决了线程安全问题。
3.4 代码实现
只需要在扣减库存的时候判断库存是否大于 0 即可。
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1、查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2、判断秒杀是否开始
if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3、判断秒杀是否结束
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4、判断库存是否充足
if(seckillVoucher.getStock() < 1){
return Result.fail("库存不足");
}
// 5、扣减库存
boolean result = seckillVoucherService.update()
// 相当于 set stock = stock-1
.setSql("stock = stock-1")
// 添加乐观锁,判断库存是否大于0
// 相当于 where id =? and stock >0
.gt("stock",0)
.eq("voucher_id", voucherId).update();
if(!result){
return Result.fail("库存不足");
}
// 6、创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户 ID
UserDTO user = UserHolder.getUser();
voucherOrder.setUserId(user.getId());
// 6.3 订单 ID
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7、返回订单id
return Result.ok(orderId);
}
四、一人一单
4.1 需求描述
现在需要修改秒杀业务,要求同一个优惠券,一个用户只能下单一次,不允许下单多次。现在的秒杀业务的流程图如下所示。
首先判断秒杀是否开始,然后判断库存是否充足,然后判断订单是否存在,最终返回订单 id 即可。
4.2 编码实现
对应的 service 层的实现方法如下所示:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
ISeckillVoucherService seckillVoucherService;
@Resource
RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1、查询优惠券
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 2、判断秒杀是否开始
if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3、判断秒杀是否结束
if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4、判断库存是否充足
if(seckillVoucher.getStock() < 1){
return Result.fail("库存不足");
}
// 5、一人一单,以 userId 来加锁,缩小范围
Long userId = UserHolder.getUser().getId();
// 因为 userId.toString() 每次返回的都是新的字符串对象,即便 userId 相同,那么锁对象也会发生变化
// 需要调用字符串的 intern() 方法去常量池里面找一找有没有一样的,这样就解决了
synchronized (userId.toString().intern()){
// 由于 createVoucherOrder 添加了事务控制,而 seckillVoucher 没有添加事务控制
// this 获取的是 VoucherOrderServiceImpl 对象,而不是代理对象
// 一个事务如果想要生效,是 spring 对 VoucherOrderServiceImpl 做了动态代理,拿到了代理对象,用代理对象做了事务处理。
// 而下面的 this 指的是非代理对象,即没有事务功能的
// 为了解决这个问题,需要拿到 spring 事务代理的这个对象
VoucherOrderServiceImpl proxy = (VoucherOrderServiceImpl)AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
// 在方法内部加锁会存在一个问题,会先释放锁,然后再提交事务,如果有线程在释放锁后提交事务之前一瞬间进来,就会发生线程安全问题。
// 得出结论:我们的锁的范围小了,我们应该保证事务提交之后,我们再释放锁
@Transactional
public Result createVoucherOrder(Long voucherId){
// 6.1 查询订单
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 6.2 判断订单是否已经存在
if(count !=0){
return Result.fail("用户已经购买过一次");
}
// 7、扣减库存
boolean result = seckillVoucherService.update()
// 相当于 set stock = stock-1
.setSql("stock = stock-1")
// 添加乐观锁,判断库存是否大于0
// 相当于 where id =? and stock >0
.gt("stock",0)
.eq("voucher_id", voucherId).update();
if(!result){
return Result.fail("库存不足");
}
// 8、创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 8.1 订单ID
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 8.2 用户 ID
UserDTO user = UserHolder.getUser();
voucherOrder.setUserId(user.getId());
// 8.3 订单 ID
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 9、返回订单id
return Result.ok(orderId);
}
}
为了创建代理对象还需要引入 aspectj 的相关依赖,如下:
<!--添加 aspectj 的依赖-->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
还需要在启动类上添加暴露代理对象的属性,如下所示:
@EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理对象
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
使用 jemter 进行压力测试,可以看到,即使启动了几百个线程去访问,最终也只会创建一个订单,如下图:
4.3 并发安全问题
4.3.1 现象演示
我们可以通过加锁在解决单机情况下的一人一单的线程安全问题,但是在集群模式下就不行了。首先我们将服务启动两份,端口分别为 8081 和 8082,如下图:
然后修改 nginx 的 conf 目录下的 nginx.conf 文件,配置反向代理和负载均衡,如下图:
现在,用户请求会在这两个节点上负载均衡,再次测试下是否存在线程安全问题。我们使用 postman 用同一个用户发送两次请求,测试是否存在线程安全问题,如下图,确实出现了线程安全问题(测试的时候需要打上断点,让两个线程依次进入方法内部才可以演示这种情况)。
可以得出结论:在集群模式下,虽然使用了 synchronized 关键字,但是并没有锁住,还是出现了线程安全问题。
4.3.2 问题分析
单机场景下的线程执行流程图如下所示,在 JVM 内部维护了一个锁监视器对象,我们使用用户 id 充当锁监视器对象,所以当用户 id 相同时锁的监视器永远是同一个。
当我们做集群部署的时候,一个新的部署就是一个新的 tomcat,也就是一个全新的 JVM。总的来说就是在集群模式下,有多个 JVM 的存在,每个 JVM 都有自己的锁,导致每个线程都能在自己的 JVM 里面获取到锁,就可能出现线程安全问题。
要想解决这个问题,就必须让多个 JVM 使用同一把锁,需要我们自己去实现。