本次项目用到的两张表
tb_voucher:优惠券的基本信息,优惠券金额、使用规制等
CREATE TABLE `tb_voucher` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', `shop_id` bigint unsigned DEFAULT NULL COMMENT '商铺id', `title` varchar(255) NOT NULL COMMENT '代金券标题', `sub_title` varchar(255) DEFAULT NULL COMMENT '副标题', `rules` varchar(1024) DEFAULT NULL COMMENT '使用规则', `pay_value` bigint unsigned NOT NULL COMMENT '支付金额,单位是分。例如200代表2元', `actual_value` bigint NOT NULL COMMENT '抵扣金额,单位是分。例如200代表2元', `type` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '0,普通券;1,秒杀券', `status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '1,上架; 2,下架; 3,过期', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4;
|
tb_seckill_voucher:优惠券的库存、开始抢购时间。特价优惠券才需要填写这些信息
CREATE TABLE `tb_seckill_voucher` ( `voucher_id` bigint unsigned NOT NULL COMMENT '关联的优惠券的id', `stock` int NOT NULL COMMENT '库存', `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `begin_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间', `end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '失效时间', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`voucher_id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT COMMENT='秒杀优惠券表,与优惠券是一对一关系';
|
问题分析
我们先来分析一下优惠券秒杀项目中会出现的问题:
- 全局唯一ID问题
- 超卖问题
- 一人一单问题
全局唯一ID
使用Reids自定义全局ID生成器,要在分布式系统中使用需要满足:唯一性、高可用、高性能、递增型、安全性。
为了增加ID安全性,不可直接使用Redis自增的数值,而是拼接一些其他信息
ID的组成部分:
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
点击查看实现代码
@Component public class RedisIdWorker {
private static final long BEGIN_TIMESTAMP = 1640995200L;
private static final int COUNT_BITS = 32;
private final StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; }
public long nextId(String keyPrefix) { LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")); long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date); return timestamp << COUNT_BITS | count; } }
|
超卖问题
问题分析
假设当券剩下最后一张时,有三个线程(用户)同时抢购,线程1先查询结果有1个,这时线程2和3在1扣减之前先查询了结果也是1,此时线程1先去执行扣减操作,这是数据库中券为0。由于线程2和3查询也是1,所以也去扣减,超卖问题出现了。
解决方案
超买问题就是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
悲观锁
- 认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行
- 例如Synchronized、Lock都属于悲观锁
乐观锁
- 认为线程安全问题不一定会发生,因此不加锁,只是在更新时有没有其它线程对数据做修改
- 如果没有修改则认为是安全的,自己才更新数据
- 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常
乐观锁
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的两种方式:
版本号法
在操作数据之前先查询version,修改时判断version是否和之前查询的一致
set stock = stock - 1, version = version + 1 where id = 10 and version = 1
|
CAS法
在操作数据之前先查询stock,修改时判断stock是否和之前查询的一致
set stock = stock - 1 where id = 10 and stock = 1
|
代码实操
在减少库存代码中,加入对stock的判断
@Transactional @Override public boolean cutVoucher(Long voucherId, Integer stock) { return update() .setSql("stock = stock-1") .eq("voucher_id", voucherId) .eq("stock", stock) .update(); }
|
这样改存在失败率太高的问题,其实只要减少时的stock大于0就可以执行扣减操作
@Transactional @Override public boolean cutVoucher(Long voucherId) { return update() .setSql("stock = stock-1") .eq("voucher_id", voucherId) .gt("stock", 0) .update(); }
|
总结
超卖这样的线程安全问题,解决方案有哪些?
- 悲观锁:添加同步锁,让线程串行执行
- 乐观锁:不加锁,在更新时判断是否有其他线程在修改
一人一单
每个用户只能抢购一个优惠券
解决思路
我们可以看到是先查询订单,之后再进行判断和扣减库操作,也存在线程安全问题。
这里我们直接使用Redisson,进行加锁处理。
Redssion时一个在Redis的基础上实现的Java常驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
代码实操
我们在pom文件中引入Redisson的依赖
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.13.6</version> </dependency>
|
再通过@Bean的方式交给Spring管理
@Configuration public class RedisConfig {
@Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("123123"); return Redisson.create(config); } }
|
先看一下简单的使用
点击查看使用代码
@Resource private IVoucherOrderService voucherOrderService;
@Resource private RedissonClient redissonClient;
@Transactional public Result createVoucherOrder(Long voucherId) { Long userId = UserHolder.getUser().getId(); RLock redisLock = redissonClient.getLock("lock:order:" + userId); boolean isLock = redisLock.tryLock(); if (!isLock) { return Result.fail("不允许重复下单"); } try { int count = voucherOrderService.query().eq("user_id", userId).eq("voucher_id", voucherId).count(); if (count > 0) { return Result.fail("您已经领过该券!"); } boolean success = seckillVoucherService.cutVoucher(voucherId); if (!success) { return Result.fail("库存不足"); } Long orderId = voucherOrderService.seckillVoucher(voucherId); return Result.ok(orderId); } finally { redisLock.unlock(); }
}
|