Redis商品秒杀
扎哇太枣糕 人气:0全局唯一ID
业务逻辑分析
全局唯一ID是针对销量比较大的一些商品而言的,这类商品的成交量比较多,用户购买成功就会生成对应订单信息并保存到一张表中,而订单表的id如果使用数据库自增ID就存在一些问题,比如说id的规律性太强导致安全性极低,还有如果订单数量太多一张表存不下分成多张表存储的话就会出现ID冲突问题,于是我们需要一个全局ID生成器,保证ID在全局中都是唯一的
使用Redis即可完成这种全局ID生成器的功能,具体实现就是一种类雪花算法,也就是符号位、时间戳、序列号三部分拼接形成一个ID,逻辑就是符号位0代表整数,时间戳确定具体到下订单的时候是哪一秒,至于序列号就是用于区分这一秒的订单,序列号使用redis的值自增来保证所有序列号不一致,原则上一秒中最多可以有232个不同的ID
代码实现
@Component public class RedisIdGenerator { /** * 构造方法注入stringRedisTemplate对象 */ private StringRedisTemplate stringRedisTemplate; public RedisIdGenerator(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } // 定义序列号的位数 private static final int COUNT_BITS = 30; public long nextId(String keyPrefix) { // 生成从指定时间到现在的时间戳 LocalDateTime beginTime = LocalDateTime.of(2022, 1, 1, 0, 0, 0); long beginTimeStamp = beginTime.toEpochSecond(ZoneOffset.UTC); long endTimeStamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); long timeStamp = endTimeStamp - beginTimeStamp; /** * 生成序列号 使用redis的incr方法 K值为"icr:" + keyPrefix + ":" + date * 也就是按照日期作为K 每下一次单V就自增1作为序列号添加到后面 * 这样的话既避免了K固定带来的V超过最大阈值(redis中的V最大为2^64) * 而且还方便了统计一天、一个月、一年的订单量,在这段时间内最大的序列号就是它的最多订单数 */ String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); Long sequenceId = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date); // 拼接生成全局唯一ID并返回 两个二进制的拼接可以使用前一个数左移一定位数 后一个数与位移后的进行或运算 return timeStamp << COUNT_BITS | sequenceId; } }
优惠券秒杀
业务逻辑分析
用户对秒杀商品下单的时候,后台业务需要先完成对商品时间的判断,判断该商品的秒杀活动是否开始或者有没有结束,但凡还未开始或者已经结束都无法下单;时间信息正确的话就判断该商品的活动库存还有没有剩余,如果已经卖完的话也无法下单。时间和库存的判断都是通过前端传过来的优惠券id,查出来该优惠券的时间和库存信息,如果条件都满足的话,将该商品券的库存扣除,然后创建订单返回订单id
代码实现
controller层主要就是调用service接口里的secKillVoucher方法,所以整个业务逻辑代码全部都在接口的实现类中完成
@Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIdGenerator generator; @Override @Transactional public Result secKillVoucher(Long voucherId) { // 查询优惠券 SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId); // 获取时间 判断秒杀活动是否开始或者结束 if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) { return Result.fail("活动暂未开始"); } else if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) { return Result.fail("活动已经结束"); } // 判断库存是否充足 if (seckillVoucher.getStock() < 1) { return Result.fail("库存不足,活动结束"); } // 扣减库存 seckillVoucherService.update() .setSql("stock = stock - 1") .eq("voucher_id", voucherId).update(); // 创建订单 并返回id VoucherOrder order = new VoucherOrder(); // 订单id(redis全局唯一id) 下单用户id(拦截器中做登录验证的用户id) 优惠券id(直接传过来的id) long orderId = generator.nextId("order"); order.setId(orderId); order.setUserId(UserHolder.getUser().getId()); order.setVoucherId(voucherId); save(order); return Result.ok(orderId); }
定量商品多卖问题
业务逻辑分析
像上面的优惠券秒杀的业务,优惠券或者商品的数量一般都是固定的,如果把这些数量都卖完之后应该就结束这个活动。但是现实中的秒杀业务都是多线程的,很多的用户同时等着活动开启一起点击下单,这样的话就极有可能出现线程安全问题也就是说最终成交的数量要多于活动商品的数量
上述问题出现的原因就是多线程之间的执行顺序所引起,我们的秒杀业务里面是先查询库存数量大于1就产生订单,但是多线程之间的执行不会严格的按照这个顺序执行,而是交叉执行,如果最后只剩一张票的时候进来了两个线程AB,A查完B查AB查询结果都可以下单,A产生订单B再产生订单,此时就已经产生超卖
乐观锁与悲观锁
解决线程问题的最好方法就是加锁,但是锁也分为悲观锁和乐观锁,悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行,例如Synchronized、Lock等。乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改,如果没有修改则更新数据,修改说明发生了安全问题
很显然乐观锁的性能要显著高于悲观锁,因此采用乐观锁保证线程的原子性。乐观锁又有两种解决方案:版本号是指对修改的数据附带一个version字段值,每次更新的时候判断修改时的version与查询的时候是否一致,一致则修改。CAS机制全称为Compare And Swap译为先比较再交换,也就是将修改的数据本身作为版本号,每次更新的时候判断修改时的数据值与查询时的值是否相同,相同则修改,不同就说明发生了线程安全问题,在我们的这个售卖业务中,可以设置成只要库存大于0就可以执行成功
乐观锁代码实现
乐观锁的核心就是,在更新数据的时候(也就是减少库存),判断一下库存是否大于0,如果判断失败的话也应该使该线程任务失败
// 扣减库存 boolean update = seckillVoucherService.update() .setSql("stock = stock - 1") .eq("voucher_id", voucherId) .gt("stock", 0) .update(); // 更新失败说明在扣除库存的时候 库存小于等于0 if (!update) { return Result.fail("库存不足!"); }
一个用户限买一单
业务逻辑分析
按照正常的业务逻辑,秒杀应该限制一个用户只能购买一次该商品,最简单的方法就是对user_id使用唯一索引,如果user_id重复就会抛出相关异常,但是这需要修改表结构。如果不修改标结果的话就需要扣除库存之前根据voucher_id和user_id查询订单表,如果存在的话就返回错误,否则说明该用户还未购买
代码实现
单机(服务部署在一台tomcat服务器)的情况下,加synchronized 锁即可解决(查询判断用户是否下单和创建订单)业务的线程安全问题,但是这种情况就只能
// 单用户id(拦截器中做登录验证的用户id) Long userId = UserHolder.getUser().getId(); // 根据user_id加锁 intern方法是去字符常量池中查找值相同的,不加的话字符串值一样的地址不一样也会加上锁 synchronized (userId.toString().intern()) { // 查询优惠券 // 判断库存是否充足 // user_id和voucher_id联合查询订单数 Integer count = query().eq("user_id", userId) .eq("voucher_id", voucherId) .count(); // 订单数为1 就说明已经下过单了 if (count.equals(1)) { return Result.fail("您已经购买过该商品了"); } // 扣减库存 创建订单 return Result.ok(orderId); }
以上加synchronized 锁的解决方案只适用于单机模式下,此时所有的请求过来都会按照userId去常量池中查找是否一致,一致的话就锁在一起防止一个用户购买多单。但是集群模式下所有的请求会经过Nginx的负载均衡轮询发送到集群上的所有服务器,如果一个用户的多个请求被分配到不同的服务器上的话,不同服务器中的JVM虚拟机里的静态常量池中的内容是不同步的,这样的话就会导致虽然userId一致但是各自所在的静态常量池中都没有,于是这个用户就可以在不同的服务器分别下单了。如果有用户使用脚本同时发送很多的下单请求,那么就会有极大的可能在每一个服务器中都下一单,那么如何解决这个问题呢?那就要学习分布式锁的内容了
加载全部内容