文章目录
一、服务整体流程
优惠券秒杀这一块的逻辑总的来说就两个方法,新增秒杀券和秒杀业务。
新增优惠券只需要前端发送优惠券详情给后端,后端存数据库和redis即可;
秒杀优惠券的流程也并不复杂,但实现相对比较麻烦,要考虑的问题比较多,如:超卖、一人一单、分布式锁、处理性能等等问题。
更加详细的分析点击这里查看。
新增秒杀券流程:
① 前端将要新增的秒杀券信息发送至后端接口
② 后端接口将信息处理后存入数据库与Redis中
秒杀优惠券流程:
① 前端点击抢购优惠券,将优惠券ID发送至后台
② 后端接收前端请求,首先根据优惠券ID查询该优惠券秒杀是否开始或结束,若优惠券秒杀已经开始且尚未结束,则进一步查询优惠券库存是否充足
③ 若②中的条件都满足了,则获取当前用户的ID,根据用户ID和优惠券ID查询该用户是否已经购买了该优惠券
④ 若用户没有购买过该订单,则生成对应的订单,同时修改数据库和Redis中的相关信息
⑤ 返回本次订单ID
二、具体业务逻辑实现
注释已经写的很明白了,不多做赘述。
持久层框架用的Mybatis-Plus。
1. 新增秒杀券
1.1. Controller层
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {//参数为优惠券具体信息
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
1.2. Service层
@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);
//保存秒杀库存到Redis
stringRedisTemplate.opsForValue().set(RedisConstants.SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
}
2. 秒杀优惠券
2.1. 具体业务流程以及细节说明
① 前端发送请求(携带订单优惠券)到后端;
② 后端首先会进入拦截器,拦截器会从请求头中拿到带有用户信息的Token,存入当前线程的ThreadLocal中,具体流程可以点击这里查看(这一块是基于Redis实现的登录,本文不多赘述);这一步主要是将当前用户信息拿到,也可以通过JWT实现单点登录的方法,从token中拿到用户信息;
③ 通过拦截器后正式开始进行秒杀业务,Controller将优惠券id交给Service处理
④ 通过static静态代码块,在类加载的时候就开始读取LUA脚本,在执行秒杀业务的方法时,首先获取用户ID,根据用户ID和前端传递的秒杀券ID,调用LUA脚本校验时间、库存以及用户是否下单,如果通过验证则扣减库存和保存用户数据。这一步都是调用LUA脚本在Redis中进行的操作(本次省略后端校验时间,时间校验也就是存redis的时候多存两个日期(hash形式),调用LUA的时候多传一个时间参数)。
⑤ 执行完LUA获取到执行结果,根据执行结果判断是否可以购买,不可以直接返回原因,可以继续向下
⑥ 有购买资格,把下单信息保存至阻塞队列中。
⑦ 阻塞队列此处用的是JUC中的阻塞队列。利用@PostConstruct注解,在项目启动的时候就开始执行一个异步线程,一旦阻塞队列中有了消息,就开始处理消息,没有则等待消息。
⑧ 首先获取到用户ID,然后利用Redisson创建分布式锁,获取锁,获取不到锁则直接返回“不允许重复下单”。当获取到锁之后,扣减库存并创建订单,最后释放锁,结束。
2.2. Controller
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {//参数为优惠券ID
return voucherOrderService.seckillVoucher(voucherId);
}
2.3. 全局ID生成器(用来生成订单ID)
为了保证ID的安全性(防止被用户根据ID猜到相关信息、避免受到表单数据量的限制),使用全局ID生成器。
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足唯一性、高可用、高性能、递增性、安全性。
@Component
public class RedisIDWorker {
/**
长度为64位的ID
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以用69年
序列号:32bit,支持每秒产生2^32个不同ID
*/
//开始时间戳,2023.1.1 00:00:00
private static final long BEGIN_TIMESTAMP = 1672531200L;
//序列号位数
private static final int COUNT_BITS = 32;
private 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"));
//2.2 自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
//3. 拼接并返回
return timeStamp << COUNT_BITS | count;
}
}
2.4. Service 层
这里阻塞队列用的是JUC里面的阻塞队列,最好自行改成消息中间件
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIDWorker redisIDWorker;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private RedissonClient redissonClient;
//读取LUA脚本
private static DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
//线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
//当前类代理对象
private IVoucherOrderService proxy;
//项目启动便直接开启异步线程
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
//阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
//异步线程,处理订单
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
//1. 获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
//2. 创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
//创建订单
private void handleVoucherOrder(VoucherOrder voucherOrder) {
//1. 获取用户
Long userId = voucherOrder.getUserId();
//2. 创建锁对象
RLock lock = redissonClient.getLock("order:" + userId);
//3. 获取锁 tryLock()默认 -1 30 Second
boolean isLock = lock.tryLock();
//4. 判断是否获取锁成功
if (!isLock) {
//获取所失败,返回错误或重试
log.error("不允许重复下单");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
//秒杀业务类
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
//1.执行LUA脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
//2. 判断结果是否为0
if (r != 0) {
//2.1 不为0,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
//2.2 为0,有购买资格,把下单信息保存至阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
//2.3 订单ID--全局唯一ID生成器
Long orderId = redisIDWorker.nextId("order");
voucherOrder.setId(orderId);
//2.4 用户id
voucherOrder.setUserId(userId);
//2.5 代金券id
voucherOrder.setVoucherId(voucherId);
//2.6 放入阻塞队列
orderTasks.add(voucherOrder);
//3. 获取代理对象
proxy = (IVoucherOrderService) AopContext.currentProxy();
//4. 返回订单id
return Result.ok(orderId);
}
//处理订单数据(存库、扣减库存等)
@Transactional
@Override
public void createVoucherOrder(VoucherOrder voucherOrder) {
//5. 一人一单
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
//5.1 查询订单(这个部分其实没必要,兜底)
Integer count = lambdaQuery().eq(VoucherOrder::getUserId, userId).eq(VoucherOrder::getVoucherId, voucherId).count();
//5.2 判断是否存在
if (count > 0) {
//用户已经购买过了
log.error("用户已经购买过一次!");
return;
}
//6. 扣减库存
boolean updateFlag = seckillVoucherService.lambdaUpdate()
.setSql("stock = stock -1")
.eq(SeckillVoucher::getVoucherId, voucherId)
.gt(SeckillVoucher::getStock, 0)//CAS方式乐观锁,因为mysql在update的时候有行锁是串行的,所以可以
.update();
if (!updateFlag) {//扣减失败
log.error("库存不足");
return;
}
//7. 创建订单
save(voucherOrder);
}
}
2.5. LUA脚本
-- 1.参数列表
-- 1.1优惠券id
local voucherId = ARGV[1]
-- 1.2 用户id
local userId = ARGV[2]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get',stockKey)) <= 0) then
-- 3.2 库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId) == 1) then
-- 3.3.存在,说明是重复下单
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd',orderKey,userId)
return 0
结束