目录
四、优惠券秒杀
1.全局唯一ID
(1)介绍
在各类购物App中,都会遇到商家发放的优惠券。
当用户抢购商品时,生成的订单会保存到 tb_voucher_order 表中,而订单表如果使用数据库自增ID就会存在一些问题:
- id 规律性太明显
- 受单表数据量的限制
如果我们的订单 id 有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适。
随着我们商城的规模越来越大,MySQL 的单表容量不宜超过 500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,进行分布式存储。从逻辑上讲,这些表是同一张表,所以它们的 id 不能重复,于是乎我们就要保证 id 的唯一性。
那么这就引出我们的全局 ID 生成器了!
全局 ID 生成器是一种在分布式系统下用来生成全局唯一 ID 的工具,一般要满足一下特性:
- 唯一性
- 高可用(任何时候都可以生成一个正确的 ID,不能挂,使其它业务没法运行)
- 高性能(高并发场景下可以迅速生成 ID)
- 递增性(总体呈递增的趋势,有利于数据库创建索引,提高插入时的速度)
- 安全性(不具有明显规律性)
Redis 的 String 数据结构中有一个 incr 命令,具有自增特性。同时 Redis 独立于数据库,只有一个,所以可以确保唯一性。
高可用,Redis 将来会有集群方案,哨兵方案,主从方案可以确保。
高性能更不用说,Redis 基于内存,比数据库性能好太多太多。
至于安全性,Redis 肯定不能像数据库一样一个一个递增,这样规律性就太明显了。
所以,为了增加ID的安全性,我们不直接使用 Redis 自增的数值,而是拼接一些其他信息:
ID 组成部分:
- 符号位: 1 bit,永远为0
- 时间戳:31 bit,以秒为单位,可以使用 69 年(2^31 秒约等于 69 年)
- 序列号:32 bit,秒内的计数器,支持每秒产生 2^32 个不同 ID
最终的 id 是一个 64 bit 的数据,正好就对应 java 中的 long 类型。
(2)基于 Redis 实现
@Component
public class RedisIdWorker {
// 开始时间戳
private static final long BEGIN_TIMESTAMP = 1704067200L;//2024年1月1日的秒数
// 序列号的位数
private static final int COUNT_BITS = 32;
@Resource
private StringRedisTemplate stringRedisTemplate;
//不同业务会有不同的key进行自增长
public long nextId(String keyPrefix) {
// 1.生成时间戳
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"));// 和Redis的层级结构刚好吻合
// 2.2.自增长(key不存在,redis会自动创建,值为0进行自增)
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
细节:
在使用 key 进行自增长时,需要注意:
① 不同业务的 key 肯定是不一样的,不可能所有业务用同一个 key 进行自增长,所以需要传递一个前缀。
② 同一个业务也不能总是使用同一个 key 进行自增长,因为序列号有 32 bit,所以这个 key 最多只能自增到 2^32。如果是同一个 key 进行自增长,总有一天会超过 2^32,所以我们不能永远使用同一个 key。
我们可以在 key 后面加上一个后缀,表示当天的日期,这样这个 key 只会用这一天(从 1~2^32)。到了第二天,又是一个新的 key,又会重新从 1 开始递增。
这样做还有一个好处,如果我想统计今天下了多少单,我只要看对应的 key 的值自增到多少就行。
测试:
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private RedisIdWorker redisIdWorker;
// 设置线程池的线程数为500
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
// 设置计数器的值(要等待的线程数)为300
CountDownLatch latch = new CountDownLatch(300);
// 定义任务:生成100个id
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
// 任务执行完,计数器-1
latch.countDown();
};
long begin = System.currentTimeMillis();
// 将任务提交给300个线程(每个线程都会生成100个id --> 一共生成3w个id)
for (int i = 0; i < 300; i++) {
es.submit(task);
}
// 阻塞当前线程(main线程),等待计数器归零,当前线程被唤醒
latch.await();
long end = System.currentTimeMillis();
// 计算生成3w的id花费的时间
System.out.println("time = " + (end - begin));
}
}
扩展:CountDownLatch 的理解与使用
概念:
- CountDownLatch 是在 jdk1.5 的时候被引入的,位于 java.util.concurrent 并发包中,CountDownLatch 叫做闭锁,也叫门闩。
- CountDownLatch 是一个同步工具类,它允许一个或多个线程一直等待,直到其他线程执行完后再执行。
举例:
班长和五个同学都在教室里面写作业,班长必须等待五个同学都走了之后,才能把教室门锁上,CountDownLatch 就提供了类似这种一个线程需要等待其他线程都执行完成之后,自己再去执行。
工作原理:
CountDownLatch 是通过一个计数器来实现的,计数器的初始化值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就相应的减 1。当计数器的值减到 0 时,表示所有的线程都已完成任务,然后在 CountDownLatch 上等待的线程就可以恢复执行接下来的任务。
常用方法:
public CountDownLatch(int count) CountDownLatch 接收一个 int 型参数,表示要等待的工作线程的个数。 public void await() 使当前线程进入同步队列进行等待,直到计数器的值减到0或者当前线程被中断,当前线程就会被唤醒。 public boolean await(long timeout, TimeUnit unit) 带超时时间的 await() public void countDown() 使计数器的值减 1,如果减到了 0,则会唤醒所有等待在这个CountDownLatch 上的线程 public long getCount() 获得 CountDownLatch 的数值,也就是计数器的值 缺点:
CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后不能再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。
运行结果:
可以看到,这里生成 3w 个 id 只花了 968ms,效率还是相当高的。
2.添加优惠券
每个店铺度可以发布优惠券,分为平价券和特价券,平价券可以任意购买,而特价券需要秒杀抢购:
表关系如下:
- tb_voucher:优惠券的基本信息,优惠金额、使用规则等
- tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息
平价券由于优惠力度并不是很大,所以是可以任意领取。
而代金券由于优惠力度大,所以像第二种券,就得限制数量,从表结构上也能看出,特价券除了具有优惠券的基本信息以外,还具有库存,抢购时间,结束时间等等字段。
由于数据库里没有秒杀券,我们需要通过接口去添加秒杀优惠券,其代码已经提供好了
细节:
这里秒杀券表的 id 和 优惠券表的 id 是共享的,秒杀券属于优惠券。也就是说,当新增一条优惠券,优惠券表的 id 会自增。如果这个新增的优惠券又是秒杀券,会根据此 id 去秒杀券表中新增对应的一条数据。
由于这里并没有后台管理页面,所以我们只能用 postman 模拟发送请求来新增秒杀券.
请求路径为:http://localhost:8081/voucher/seckill, 请求方式POST,JSON数据如下:
{
"shopId":1,
"title":"100元代金券",
"subTitle":"周一至周五可用",
"rules":"全场通用\\n无需预约\\n可无限叠加",
"payValue":8000,//单位:分
"actualValue":10000,
"type":1,
"stock":100,
"beginTime":"2024-12-10T21:00:00",
"endTime":"2024-12-10T23:59:59"
}
注意优惠券的截止日期设置,若优惠券过期,则不会在页面上显示。
此时,一号店铺就新增了秒杀优惠券
3.实现优惠卷秒杀下单
我们点击限时抢购,然后查看发送的请求:
下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
代码实现:
VoucherOrderController
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {
@Resource
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
}
IVoucherOrderService
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
}
VoucherOrderServiceImpl
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始,返回错误
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束,返回错误
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).update();
if(!success){
return Result.fail("库存不足!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2.用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3.代金券id
voucherOrder.setVoucherId(voucherId);
// 4.4.订单写入数据库
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
}
运行结果:
至此,我们的下单功能已经实现,但还没有涉及到秒杀业务。
4.超卖问题
(1)业务分析
我们虽然进行测试后,发现可以正常下单。
但这个测试和真实的秒杀场景还是相差甚远,真实的秒杀场景会有无数的用户一起去点击测试,那么这一瞬间的并发量可能高达每秒钟成千上万。
在这种情况下,我们的接口还能否正常工作?
为了更好的检测结果,我们可以将数据库中刚生成的订单删掉,秒杀券的库存改回 100。
打开 JMeter 进行测试:
注意:一定要在请求头中携带 token,否则会报 401 错误!!!
运行结果:
可以看到有一部分成功了,有一部分失败了。
打开数据库:
我们发现,原本只能卖 100 件,实际却卖出了 109 件。
证明在高并发场景下,我们的库存出现了超卖现象!这是秒杀场景下往往不能出现,也是不可接受的问题!
为什么会产生超卖问题呢?让我们看看代码:
正常情况:
异常情况:
假设现在只剩下一张优惠券,线程 1 过来查询库存,判断库存数大于 1,但还没来得及去扣减库存,此时库线程 2 也过来查询库存,发现库存数也大于 1。那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题。
实际上,只要在线程 1 扣减库存之前,只要有其他线程进来查询,发现库存大于 1,都会进行扣减库存。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
悲观锁的实现比较简单,这里就不做过多讲解了。
对于乐观锁,其实我们之前在 MybatisPlus 中就已经遇到了,不记得的可以回去看看:
常见的实现方式有两种:
① 版本号法:
要求数据库表额外会有一个字段:版本号,每次操作数据前,会查询一下当前的版本号。修改数据时,会去校验版本号是否和之前查询到的一致。
- 如果一致 ,则进行修改操作,操作过程中将版本号 +1。
- 如果不一致,则表示这之间已经有其他线程进行了修改操作,当前线程就放弃修改。
这套机制的核心逻辑在于,如果在操作过程中,版本号如果没变 ,那么就意味着操作过程中没有人对他进行过修改,它的操作就是安全的,如果改变,则数据被修改过。
② CAS 法:
使用原子操作比较当前值与旧值是否一致,若一致则进行更新操作,否则重新尝试。
简单来说,刚才的版本号法,需要对原有的数据库表新增一个字段:版本号。而 CAS 法, 就是直接使用要修改的字段(库存)来代替版本号,比较数据是否发生了变化。这样就没必要新增字段,不用对表做修改。
即:每次操作数据前,会查询一下当前的库存。修改数据时,会去校验存库是否和之前查询到的一致。
- 如果一致 ,则进行修改操作,库存发生变化。
- 如果不一致,则表示这之间已经有其他线程进行了修改操作,当前线程就放弃修改。
两者的思想其实一致,只不过前者使用了版本号,后者没有用。
(2)代码实现
以下我们基于 CAS 法实现乐观锁机制,解决超卖问题。
按照 CAS 法的思想,我们只要在更新时判断 stock 是否发生改变(等于一开始查询到的值),如果没变,说明没有其他线程进行修改。如果变了,说明已有其他线程修改,则修改失败。
我们还原数据库中秒杀券的库存量,删除生成的秒杀券订单,重启项目,打开 JMeter 继续测试:
打开数据库:
我们发现,库存没有发生超卖,安全问题得以解决,但只卖出了 20 张,说明大多数线程都失败了,为什么会出现这种情况?
这就是 乐观锁 的缺点了:成功率过低
失败的原因在于:
在使用乐观锁过程中假设 100 个线程同时都拿到了 100 的库存,然后大家一起去进行扣减,但是100 个人中只有 1 个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败。
所以,这 99 个线程就都会失败,就造成成功率很低的情况了。
但是,理论上这 99 个线程没必要失败,因为库存还没有卖完,其实它们也可以进行修改。虽然这里发生了并发修改,但并没有业务上的安全问题。
所以,我们可以把修改时的判断条件,由 stock 是否等于旧值,替换成 stock 是否 > 0。
我们还原数据库中秒杀券的库存量,删除生成的秒杀券订单,重启项目,打开 JMeter 继续测试:
我们发现,前面的线程全成功了,后面的线程都失败了。
打开数据库:
我们发现,这里库存恰好为 0,订单数也恰好是 100,这里没有出现超卖问题,成功的数量也是刚刚好。
至此,我们的超卖问题就顺利解决了。
不过,现在的方案并不是最完美的,因为我们的线程毕竟还是要访问数据库,对于数据库的压力还是非常大的。在真正的秒杀场景下,应对高并发仅仅使用乐观锁还是不够的,所以后续我们还会继续优化。
总结:
Tips:对于乐观锁,成功率过低的问题,我们如何解决?
- 在我们的案例里,由于修改的恰好是库存,所以我们可以将判断条件改为 stock > 0。
- 对于某些其他的案例,它修改的不是库存,它只能通过判断数据有没有发生变化,来确定是否安全,在这种情况下要想提高成功率,我们如何实现?
我们可以采用分段锁的方案,将数据库的资源分成几份。
比如:库存是 200,我们可以将这 200 个库存分到 10 张表里,每张表里库存量是 20。
抢的时候,去多张表里分别去抢,这样同时就会 10 个线程能抢成功,成功率就提高了 10倍。
5.一人一单
需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张(多了就成黄牛了🤣🤣🤣)
具体操作逻辑如下:
我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
- 如果已存在,则不能下单,返回错误信息
- 如果不存在,则继续下单,获取优惠券
代码实现:
VoucherOrderServiceImpl
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始,返回错误
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已经结束,返回错误
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5.一人一单
Long userId = UserHolder.getUser().getId();
// 5.1.查询订单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
// 用户已经购买过了
return Result.fail("用户已经购买过一次!");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock -1") // set stock = stock - 1
.eq("voucher_id", voucherId)
.gt("stock", 0) // where id = ? and stock > 0
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1.订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 7.2.用户id
voucherOrder.setUserId(userId);
// 7.3.代金券id
voucherOrder.setVoucherId(voucherId);
// 7.4.订单写入数据库
save(voucherOrder);
// 8.返回订单id
return Result.ok(orderId);
}
}
我们还原数据库中秒杀券的库存量,删除生成的秒杀券订单,重启项目,打开 JMeter 继续测试:
我们这里虽然 200 个请求,但请求头里都是同一个 token,也就是同一个用户发起的请求,理论上只能下一单。
打开数据库:
我们发现,这个用户下了 10 单,这说明虽然增加了查询订单的逻辑,但这个用户还是下了多单。
这是为什么呢?打开代码查看:
同样,这和刚才的库存超卖问题一样,都出现了并发线程的安全问题。
刚才的超卖问题,由于是更新操作,所以我们可以加乐观锁,但现在是插入操作,没办法使用。
所以这里,我们使用悲观锁进行实现。
代码实现:
① 我们可以把一人一单逻辑之后的代码都提取到一个 createVoucherOrder 方法中,然后给这个方法加锁。
② 同时,由于将更新操作和插入操作提取,外面的都是查询操作,所以已经不需要在原方法上加事务注解,而加在该方法上。
③ synchronized 不应该加在方法上,因为加在方法上锁的对象是 this,锁的范围是整个方法。也就意味着:任何一个用户进入这个方法,都要加这把锁,这个方法就变成了串行执行,效率会非常低!
我们仅在同一个用户的情况下,才考虑一人一单的线程安全问题。如果是不同用户,根本不需要加锁,各执行各的就行。
所以,我们这里锁对象不应该是 this,而应该是当前用户,也就是 userId。
④ userId 是一个 Long 封装类型的对象,每次获取时地址值会发生改变。所以我们需要使用 toString 方法将其转换为字符串,但要注意 toString 方法的底层也是 new 了一个String 对象,所以每次的地址也是不一样的。
所以 toString 方法之后还要调用一个 intern 方法,把字符串对象的值去字符串常量池里面的找,这样的话同一个 userId,返回的就是同一个地址了,也就是同一把锁了。
⑤ 现在的锁是加载方法内部的,这样就会存在一个问题:
执行完所有操作后,先释放锁(方法内部),再提交事务(整个方法)。事务是被 spring 进行管理的,它是在方法执行完后,由 spring 去做提交。
这个时候,在同步代码块执行完,但方法还没完全执行完前,锁其实已经被释放了,这就意味着其他线程就可以进入了。
而此时,因为方法还没结束,事务尚未提交。如果其他线程进入查询订单,由于事务还没提交,所以订单可能还没有写入数据库,这就有可能造成并发安全问题。
因此,这把锁的范围就有点小了,应该是把整个方法锁起来,确保锁在事务提交后释放,这样线程才能是安全的。
⑥ 如果直接调用 createVoucherOrder 方法事务会失效,因为这时候调用者的是 this,而不是它的代理对象。
事务要想生效,是因为 spring 对当前类进行了动态代理,拿到了代理对象,由代理对象进行事务处理。而 this 指的是非代理对象,所以它是没有事务功能的。
所以我们这里需要拿到这个代理对象,用代理对象来调用 createVoucherOrder 方法。
由于这个方法是在实现类里面定义的,代理对象拿不到,所以需要给接口定义上这个方法。
同时,这样做的话还得做两件事:
导入 aspectjweaver 依赖:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
主启动类加上 @EnableAspectJAutoProxy 注解,对外暴露代理对象(默认是不暴露的 false)。
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
完整代码:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始,返回错误
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束
if (