Redis优惠券秒杀 | 黑马点评
阿里云国内75折 回扣 微信号:monov8 |
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6 |
目录
一、全局唯一ID
订单如果用自增长会存在的问题
ID的规律性太明显了
受单表数量限制因为如果商城很大订单表数量可能很多要分库分表到时候id自增从1开始的话肯定会出现重复的。订单表为了后边方便查询肯定不能重复
1、全局ID生成器
全局id生成器是一种分布式系统下用来生成全局唯一ID的工具满足下列特征
- 唯一性
- 高可用
- 高性能生成足够快
- 递增性整体递增方便创建索引
- 安全性规律性不能太明显
Redis肯定唯一的性能也高Redis也是采用递增方案的
生成器代码Redis自增ID策略
在最后做拼接的时候我们不能直接拼接因为是long类型来接收所以我们得用位运算前面的左移动32位然后或运算后面的
key的设置是每天一个key方便订单统计也防止可能会重复
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Component
public class RedisIdWorker {
private static final long BEGIN_TIMESTAMP = 1640995200L;
private static int COUNT_BITS = 32;
@Autowired
private StringRedisTemplate stringRedisTemplate;
public Long nextId(String keyPrefix){
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long time = nowSecond - BEGIN_TIMESTAMP;
String format = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// Redis Incrby 命令将 key 中储存的数字加上指定的增量值。
// 如果 key 不存在那么 key 的值会先被初始化为 0 然后再执行 INCRBY 命令。
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + format);
return time << COUNT_BITS | count;
}
public static void main(String[] args) {
LocalDateTime of = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
long l = of.toEpochSecond(ZoneOffset.UTC);
// LocalTime类的toEpochSecond()方法用于
// 将此LocalTime转换为自1970-01-01T000000Z以来的秒数
System.out.println(l);
}
}
二、实现秒杀下单
1、基本的下单功能
下单时需要满足两点
- 秒杀是否开始或结束如果没开始或已结束则无法下单
- 库存是否充足不足则无法下单
实现代码
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 判断秒杀是否还未开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
Result.fail("秒杀尚未开始");
}
// 判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
Result.fail("秒杀已经结束");
}
// 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
Result.fail("库存不足");
}
// 扣减库存
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1").
eq("voucher_id", voucherId).update();
// 扣减失败
if(!success){
return Result.fail("库存不足");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 生成订单 id
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setVoucherId(voucherId);
// 用户 id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
}
记得方法加上事务注解一旦出问题可以回滚。
2、超卖问题
当线程1在查到还有1个库存然后开始扣除的时候在还没扣除完毕时这个时候有其他线程看到还有1个库存都会进行扣除这种情况就会存在超卖问题了。
针对这一问题常见解决方案就是加锁常见有乐观锁和悲观锁
乐观锁
关键是判断之前查询得到的数据是否被修改过常见的方式有两种
版本号法数据库多一个version来标记是否已经修改
CAS法除了多的字段版本号信息以库存信息本身有没有变化为判断依据当线程修改库存时当线程修改库存时判断当前数据库中的库存与之前查询得到的库存数据是否一致如果一致则说明线程安全可以执行扣减操作如果不一致则说明线程不安全扣减失败。
3、乐观锁解决并发问题
我们只需要在修改库存表前判断一下跟之前查到的值是否相等就行
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 判断秒杀是否还未开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
Result.fail("秒杀尚未开始");
}
// 判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
Result.fail("秒杀已经结束");
}
// 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
Result.fail("库存不足");
}
// 扣减库存
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1").
eq("voucher_id", voucherId).
eq("stock", seckillVoucher.getStock()). // 增加对库存的判断判断当前库存是否与查询出的结果一致
update();
// 扣减失败
if(!success){
return Result.fail("库存不足");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 生成订单 id
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setVoucherId(voucherId);
// 用户 id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
}
最后我们测试居然发现原本预测执行100条订单的但是实际上只有76条为什么呢
因为我们这种设置乐观锁太保守了只要查到库存与之前不一样就不能扣除库存但是实际上在库存还有很多的时候这种是不影响的还是可以扣除的。于是我们优化
// 扣减库存
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1").
eq("voucher_id", voucherId).
// 增加对库存的判断判断当前库存是否与查询出的结果一致
// eq("stock", seckillVoucher.getStock()).
// 修改判断逻辑改为只要库存大于0就允许线程扣减
gt("stock", 0).
update();
只要库存还是大于0的就能够进行修改
三、实现一人一单
需求修改秒杀业务要求一个优惠券一个用户只能下一单
1、思路分析
我们得从查询订到到判断订单到创建订单这三步都要加上悲观锁我们是同一个用户来了才需要处理这个并发安全问题不同的用户是不影响的因此加的锁应该根据用户的id来加锁
所以用synchronize(userId.toString().intern())这个来锁为什么要加intern()因为如果不加每次获取的字符串对象可能不是一个都是不一样的加了可以保证每次都是同一个他会去常量池里面找一样的返回。
2、代码初步实现
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 判断秒杀是否还未开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
Result.fail("秒杀尚未开始");
}
// 判断秒杀是否已经结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
Result.fail("秒杀已经结束");
}
// 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
Result.fail("库存不足");
}
return createVoucherOrder(voucherId);
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 判断当前优惠券用户是否已经下过单
// 用户 id
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("用户已经购买过一次");
}
// 扣减库存
boolean success = seckillVoucherService.update().
setSql("stock = stock - 1").
eq("voucher_id", voucherId).
// eq("stock", seckillVoucher.getStock()). // 增加对库存的判断判断当前库存是否与查询出的结果一致
gt("stock", 0). // 修改判断逻辑改为只要库存大于0就允许线程扣减
update();
// 扣减失败
if (!success) {
return Result.fail("库存不足");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 生成订单 id
Long orderId = redisIdWorker.nextId("order");
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(orderId);
save(voucherOrder);
return Result.ok(orderId);
}
}
}
3、关于锁的范围
这样加也有弊端如果锁的范围是这里锁先释放再提交的事务假如我们刚改完释放锁还没提交事务别人进来又改一次然后再提交事务就会出现问题。
我们必须把锁加在外面调用方法的时候锁住锁住整个方法事务先提交再释放锁
synchronize(userId.toString().intern()){
return createVoucherOrder(voucherId);
}
4、关于事务失效
这样做会导致事务失效我们现在给的是方法加的事务注解seckillVoucher这个方法没有加现在本质上是用this.createVoucherOrder来调用的这个this拿到的是当前对象来调用的而不是代理对象调用。
我们要想让事务生效是spring对当前类做了动态代理生成代理类用代理对象来做的事务处理。现在用的是非代理对象来做的所有没有事务功能。
我们要拿到事务代理对象才行。
我们可以用AopContext拿到代理对象然后用代理对象来调用方法。
这样做我们要添加一个aspectjweaver的依赖启动类添加@EnableAspectJAutoProxy(exposeProxy=true)注解来暴露代理对象
5、集群下线程并发问题
上面这种情况下只能保证单机部署下安全在集群环境还是会出现问题
我们模拟集群的环境
测试发现集群模式下synchronize锁不住为什么呢
在集群模式下每个都是不同tomcat不同jvm的存在每个jvm的每个锁都可以有一个线程来获取就会出现并行安全问题。
要想解决这种问题必须得想办法让多个jvm只能用同一个锁。分布式锁