本次项目用到的两张表

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='秒杀优惠券表,与优惠券是一对一关系';

问题分析

我们先来分析一下优惠券秒杀项目中会出现的问题:

  1. 全局唯一ID问题
  2. 超卖问题
  3. 一人一单问题

全局唯一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都属于悲观锁

乐观锁

  • 认为线程安全问题不一定会发生,因此不加锁,只是在更新时有没有其它线程对数据做修改
  • 如果没有修改则认为是安全的,自己才更新数据
  • 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常

乐观锁

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的两种方式:

  1. 版本号法

    Id stock version
    10 1 1

    在操作数据之前先查询version,修改时判断version是否和之前查询的一致

    set stock = stock - 1, version = version + 1
    where id = 10 and version = 1
  2. CAS法

    Id stock
    10 1

    在操作数据之前先查询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();
}

总结

超卖这样的线程安全问题,解决方案有哪些?

  1. 悲观锁:添加同步锁,让线程串行执行
    • 优点:简单粗暴
    • 缺点:性能一般
  2. 乐观锁:不加锁,在更新时判断是否有其他线程在修改
    • 优点:性能好
    • 缺点:存在成功率低的问题

一人一单

每个用户只能抢购一个优惠券

解决思路

我们可以看到是先查询订单,之后再进行判断和扣减库操作,也存在线程安全问题。

这里我们直接使用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();
// 添加redis地址,这里添加了单点地址,也可使用config.useClusterServers(),添加集群地址
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) {
// 获取用户ID
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();
}

}