优惠券秒杀
01.全局唯一ID
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
- 唯一性。
- 高可用。
- 高性能。
- 递增性。
- 安全性。
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息。

ID的组成部分:
- 符号位:1bit,永远为0。
- 时间戳:31bit,以秒为单位,可以使用69年。
- 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID。
02.Redis实现全局唯一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){
// 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"));
long count = stringRedisTemplate.opsForValue().increment(
RedisConstants.ID_WORKER + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
测试:这里起了300个线程,每个线程生成100个ID,总共生成30000个ID。
@Resource
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
public void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
log.debug("id: {}", id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
log.info("耗时: {}", end - begin);
}
结果:
com.hmdp.HmDianPingApplicationTests : id: 140429698114024747
com.hmdp.HmDianPingApplicationTests : id: 140429698114024749
com.hmdp.HmDianPingApplicationTests : id: 140429698114024748
com.hmdp.HmDianPingApplicationTests : id: 140429698114024750
com.hmdp.HmDianPingApplicationTests : id: 140429698114024752
com.hmdp.HmDianPingApplicationTests : id: 140429698114024751
com.hmdp.HmDianPingApplicationTests : 耗时: 2951
2023-01-14 10:18:57.477 INFO 2108 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor : Shutting down ExecutorService 'applicationTaskExecutor'
Process finished with exit code 0
全局唯一ID生成策略:
- UUID。
- Redis自增。
- snowflake算法。
- 据库自增。
Redis自增ID策略:
- 每天一个key,方便统计订单量。
- ID构造是 时间戳 + 计数器。
03.添加优惠券
接口:http://localhost:8081/voucher/seckill
请求方式:POST
请求数据:
{
"shopId": 1,
"title": "100元代金券",
"subTitle": "周一至周五均可使用",
"rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
"payValue": 8000,
"actualValue": 10000,
"type": 1,
"stock": 100,
"beginTime": "2023-01-14T10:00:00",
"endTime": "2023-01-14T23:00:00"
}
04.实现秒杀下单
需求
用户可以在店铺页面中抢购这些优惠券:

下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单。
- 库存是否充足,不足则无法下单。
实现
VoucherOrderController
/**
* 秒杀下单
* @param voucherId 优惠券ID
* @return R
*/
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
@Transactional(rollbackFor = Exception.class)
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");
log.info("订单id: {}", orderId);
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
05.库存超卖问题
使用jmeter并发测试


查看聚合报告:

发现异常率不足50%,这显然不正确。因为我们库存设置的是100,模拟200个请求去抢优惠券,只会有100个能抢到。正常情况下,异常率为50%。
此时再查看数据库:

再查看订单表:

这里就出现了库存超卖的问题。本来只有100个优惠券,现在出现了109条订单,出现了数据安全问题,必须解决。
超卖问题分析
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
悲观锁
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。
乐观锁
认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据。如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
- 版本号法。
- CAS法。
超卖这样的线程安全问题,解决方案有哪些?
悲观锁:添加同步锁,让线程串行执行。
- 优点:简单粗暴。
- 缺点:性能一般。
乐观锁:不加锁,在更新时判断是否有其它线程在修改。
- 优点:性能好
- 缺点:存在成功率低的问题。
06.乐观锁解决超卖
@Override
@Transactional(rollbackFor = Exception.class)
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)
// 乐观锁解决库存超卖
.eq("stock", voucher.getStock())
.update();
if (!success){
return Result.fail("库存不足!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
log.info("订单id: {}", orderId);
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
jmeter进行测试:


查看测试结果:


正常失败率应该是50%。
查看数据库:

卖出了41件,查看订单表:

也有41个订单,说明没有超卖。但是库存没有卖完,这也必须要解决。
需要对乐观锁的方案进行改进。只要判断当前线程减库存的时候,库存大于0就可以了。
@Override
@Transactional(rollbackFor = Exception.class)
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", voucher.getVoucherId())
// 乐观锁解决库存超卖: 当前库存 > 0
.gt("stock", 0)
.update();
if (!success){
return Result.fail("库存不足!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
log.info("订单id: {}", orderId);
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
再次使用jmeter进行测试:

发现异常率等于50%,正常。
查看库存表:

查看订单表:

也是100个订单。这样就完美解决了库存超卖问题。
07.实现一人一单功能
基本实现
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。
@Override
@Transactional(rollbackFor = Exception.class)
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 查询订单
int 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")
.eq("voucher_id", voucher.getVoucherId())
.gt("stock", 0)
.update();
if (!success){
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 订单id
long orderId = redisIdWorker.nextId("order");
log.info("订单id: {}", orderId);
voucherOrder.setId(orderId);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
使用jmeter进行测试:

查看库存表:

查看订单表:

发现同一个用户下了10单。不符合需求。
加锁完善
@Override
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("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result createVoucherOrder(Long voucherId) {
// 5.一人一单
Long userId = UserHolder.getUser().getId();
// 5.1 查询订单
int 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")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success){
return Result.fail("库存不足!");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 7.1 订单id
long orderId = redisIdWorker.nextId("order");
log.info("订单id: {}", orderId);
voucherOrder.setId(orderId);
// 7.2 用户id
voucherOrder.setUserId(userId);
// 7.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
return Result.ok(orderId);
}
因为这里需要获取代理对象(事务生效),所以需要引入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);
}
}
jmeter测试结果:

查看库存表:

查看订单表:

到这里就实现了单机环境下一人一单的功能。
08.一人一单的并发安全问题
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
- 加锁的时候,JVM有一个锁监视器来监视锁的获取。在单机环境下,是可以解决并发问题的。
- 集群模式下,不同的机器有不同的JVM锁监视器,常规的加锁手段就没有用了。考虑分布式锁。
09.分布式锁
什么是分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的实现
分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:
MySQL | Redis | Zookeeper | |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
基于Redis的分布式锁
实现分布式锁时需要实现的两个基本方法:
获取锁:
互斥:确保只能有一个线程获取锁。
非阻塞:尝试一次,成功返回true,失败返回false。
# 添加锁,NX是互斥、EX是设置超时时间 SET lock thread1 NX EX 10
释放锁:
手动释放。
# 释放锁,删除即可 DEL key
超时释放:获取锁时添加一个超时时间。