Redis实战篇 | Kyle's Blog (cyborg2077.github.io)
目录
一、Redis实现全局唯一id
二、添加优惠卷
三、实现秒杀下单
四、解决超卖问题(库存为负)
乐观锁解决超卖问题(CAS法)
五、实现一人一单
编辑 悲观锁解决一人一单问题
六、集群环境下的并发问题(引出分布式锁)
一、Redis实现全局唯一id
- 在各类购物App中,都会遇到商家发放的优惠券
- 当用户抢购商品时,生成的订单会保存到
tb_voucher_order
表中,而订单表如果使用数据库自增ID就会存在一些问题- id规律性太明显
- 受单表数据量的限制
- 如果我们的订单id有太明显的规律,那么对于用户或者竞争对手,就很容易猜测出我们的一些敏感信息,例如商城一天之内能卖出多少单,这明显不合适
- 随着我们商城的规模越来越大,MySQL的单表容量不宜超过500W,数据量过大之后,我们就要进行拆库拆表,拆分表了之后,他们从逻辑上讲,是同一张表,所以他们的id不能重复,于是乎我们就要保证id的唯一性
- 那么这就引出我们的
全局ID生成器
了
- 为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他信息
- ID组成部分
- 符号位:1bit,永远为0
- 时间戳:31bit,以秒为单位,可以使用69年(2^31秒约等于69年)
- 序列号:32bit,秒内的计数器,支持每秒传输2^32个不同ID
全局唯一id:
@Component
public class RedisIdWorker {/*** 开始时间戳*/private static final long BEGIN_TIMESTAMP = 1640995200L;/*** 序列号的位数*/private static final long COUNT_BITS = 32L;@Autowiredprivate StringRedisTemplate stringRedisTemplate;// 根据传进的参数区分不同的业务,生成唯一idpublic long nextId(String keyPrefix){// 1. 生成时间戳//获取当前时间 转换为秒, 当前时间减起始时间为时间戳LocalDateTime now = LocalDateTime.now();long nowSecond = now.toEpochSecond(ZoneOffset.UTC);long nowTimeStamp = nowSecond - BEGIN_TIMESTAMP;// 2. 生成序列号//获取当前日期,精确到天, 设置序列号自增长String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);// 3. 拼接并返回return nowTimeStamp << COUNT_BITS | count;}
}
二、添加优惠卷
由于这里并没有后台管理页面,所以我们只能用POSTMAN模拟发送请求来新增秒杀券,请求路径http://localhost:8081/voucher/seckill
, 请求方式POST
新增普通券,也就只是将普通券的信息保存到表中
/*** 新增普通券* @param voucher 优惠券信息* @return 优惠券id*/
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {voucherService.save(voucher);return Result.ok(voucher.getId());
}
新增秒杀券主要看addSeckillVoucher
中的业务逻辑
/*** 新增秒杀券* @param voucher 优惠券信息,包含秒杀信息* @return 优惠券id*/
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {voucherService.addSeckillVoucher(voucher);return Result.ok(voucher.getId());
}
秒杀券可以看做是一种特殊的普通券,将普通券信息保存到普通券表中,同时将秒杀券的数据保存到秒杀券表中,通过券的ID进行关联
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {// 保存优惠券save(voucher);// 保存秒杀信息SeckillVoucher seckillVoucher = new SeckillVoucher();// 关联普通券idseckillVoucher.setVoucherId(voucher.getId());// 设置库存seckillVoucher.setStock(voucher.getStock());// 设置开始时间seckillVoucher.setBeginTime(voucher.getBeginTime());// 设置结束时间seckillVoucher.setEndTime(voucher.getEndTime());// 保存信息到秒杀券表中seckillVoucherService.save(seckillVoucher);
}
三、实现秒杀下单
实现类
@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic 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(全局唯一id)long orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 6.2.用户id (从拦截器中获取)Long userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);// 6.3.代金券idvoucherOrder.setVoucherId(voucherId);// 6.4.保存订单save(voucherOrder);// 7.返回订单idreturn Result.ok(orderId);}
}
接口
public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);
}
Controller
@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {@Autowiredprivate IVoucherOrderService voucherOrderService;@PostMapping("seckill/{id}")public Result seckillVoucher(@PathVariable("id") Long voucherId) {return voucherOrderService.seckillVoucher(voucherId);}
}
四、解决超卖问题(库存为负)
实现秒杀下单的代码其实是有问题的,当遇到高并发场景时,会出现超卖现象,我们可以用Jmeter开200个线程来模拟抢优惠券的场景。
测试完毕之后,查看数据库中的订单表,我们明明只设置了100张优惠券,却有166条数据,去优惠券表查看,库存为-66,超卖了66张
- 那么如何解决这个问题呢?先来看看我们的代码中是怎么写的
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");
}
//5. 扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
if (!success) {return Result.fail("库存不足");
}
- 假设现在只剩下一张优惠券,线程1过来查询库存,判断库存数大于1,但还没来得及去扣减库存,此时库线程2也过来查询库存,发现库存数也大于1,那么这两个线程都会进行扣减库存操作,最终相当于是多个线程都进行了扣减库存,那么此时就会出现超卖问题
- 超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:而对于加锁,我们通常有两种解决方案
乐观锁解决超卖问题(CAS法)
以上逻辑的核心含义是:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败。因为我们还需要判断是否有剩余优惠券,即只要数据库中的库存大于0,都能顺利完成扣减库存操作
修改“实现秒杀下单”的代码为:
@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic 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")// set stock = stock - 1.eq("voucher_id", voucherId).gt("stock", 0)// where stock > 0 库存大于0就扣减库存.update();if (!success) {return Result.fail("库存不足");}// 6.创建订单VoucherOrder voucherOrder = new VoucherOrder();// 6.1.订单id(全局唯一id)long orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 6.2.用户id (从拦截器中获取)Long userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);// 6.3.代金券idvoucherOrder.setVoucherId(voucherId);// 6.4.保存订单save(voucherOrder);// 7.返回订单idreturn Result.ok(orderId);}
}
五、实现一人一单
- 需求:修改秒杀业务,要求同一个优惠券,一个用户只能抢一张
- 具体操作逻辑如下:我们在判断库存是否充足之后,根据我们保存的订单数据,判断用户订单是否已存在
- 如果已存在,则不能下单,返回错误信息
- 如果不存在,则继续下单,获取优惠券
悲观锁解决一人一单问题
实现类
@Service
@Transactional
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic Result seckillVoucher(Long voucherId) {LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();//1. 查询优惠券queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);//2. 判断秒杀时间是否开始if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {return Result.fail("秒杀还未开始,请耐心等待");}//3. 判断秒杀时间是否结束if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {return Result.fail("秒杀已经结束!");}//4. 判断库存是否充足if (seckillVoucher.getStock() < 1) {return Result.fail("优惠券已被抢光了哦,下次记得手速快点");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {// 获取当前代理对象 确保下面的事务生效IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);}}@Transactionalpublic Result createVoucherOrder(Long voucherId) {// 一人一单逻辑Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {int count = query().eq("voucher_id", voucherId).eq("user_id", userId).count();if (count > 0) {return Result.fail("你已经抢过优惠券了哦");}//5. 扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();if (!success) {return Result.fail("库存不足");}//6. 创建订单VoucherOrder voucherOrder = new VoucherOrder();//6.1 设置订单idlong orderId = redisIdWorker.nextId("order");//6.2 设置用户idLong id = UserHolder.getUser().getId();//6.3 设置代金券idvoucherOrder.setVoucherId(voucherId);voucherOrder.setId(orderId);voucherOrder.setUserId(id);//7. 将订单数据保存到表中save(voucherOrder);//8. 返回订单idreturn Result.ok(orderId);}//执行到这里,锁已经被释放了,但是可能当前事务还未提交,如果此时有线程进来,不能确保事务不出问题}
}
启动类添加
@EnableAspectJAutoProxy(exposeProxy = true)
依赖
<dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId></dependency>
接口
public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);Result createVoucherOrder(Long voucherId);
}
六、集群环境下的并发问题(引出分布式锁)
-
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了
- 我们将服务启动两份,端口分别为8081和8082
- 然后修改nginx的config目录下的nginx.conf文件,配置反向代理和负载均衡(默认轮询就行)
-
具体操作,我们使用
POSTMAN
发送两次请求,header携带同一用户的token,尝试用同一账号抢两张优惠券,发现是可行的。 -
失败原因分析:由于我们部署了多个Tomcat,每个Tomcat都有一个属于自己的jvm,那么假设在服务器A的Tomcat内部,有两个线程,即线程1和线程2,这两个线程使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。但是如果在Tomcat的内部,又有两个线程,但是他们的锁对象虽然写的和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2互斥
-
这就是集群环境下,syn锁失效的原因,在这种情况下,我们需要使用分布式锁来解决这个问题,让锁不存在于每个jvm的内部,而是让所有jvm公用外部的一把锁(Redis)
1. 添加Tomcat 形成集群
改端口
2. 修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡
worker_processes 1;events {worker_connections 1024;
}http {include mime.types;default_type application/json;sendfile on;keepalive_timeout 65;server {listen 8080;server_name localhost;# 指定前端项目所在的位置location / {root html/hmdp;index index.html index.htm;}error_page 500 502 503 504 /50x.html;location = /50x.html {root html;}//反向代理location /api { default_type application/json;#internal; keepalive_timeout 30s; keepalive_requests 1000; #支持keep-alive proxy_http_version 1.1; rewrite /api(/.*) $1 break; proxy_pass_request_headers on;#more_clear_input_headers Accept-Encoding; proxy_next_upstream error timeout; #proxy_pass http://127.0.0.1:8081;proxy_pass http://backend;}}
//负载均衡(轮询)upstream backend {server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;}
}
启动nginx输入该命令重新配置nginx
nginx.exe -s reload