0 目录
1 全局ID生成器 1.1 背景 使用数据库自增ID会存在两个问题:
id的规律性太明显(如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适)
受单表数据量的限制(随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。)
因此就需要就一个全局ID生成器 ,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
唯一性:想到了关键字incrby
高可用:集群方案、主从方案、哨兵方案
高性能:内存存储性能好
递增:采用递增
安全性:自增然后再拼接一些其它信息,让规律不要那么明显
1.2 组成 数据库的id选择数值类型(占用空间更小),对应Java的Long(8字节,64比特位)
符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生232个不同ID
1.3 代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 @Component public class RedisWorker { private static final long BEGIN_TIMESTAMP = 计算出当前的时间戳; private static final int COUNT_BITS = 32 ; @Resource private 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 sequence = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date); long id = (timestamp << COUNT_BITS) | sequence; return id; } }
1.4 多线程测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 @Test public void getId () throws InterruptedException { ExecutorService pools = CacheClient.newFixedThreadPool(500 ); CountDownLatch latch = new CountDownLatch (300 ); Runnable runnable = new Runnable () { @Override public void run () { for (int i = 0 ; i < 100 ; i++) { long id = redisWorker.nextID("order" ); System.out.println("id:" + id); } latch.countDown(); } }; runnable.run(); long begin = System.currentTimeMillis(); for (int i = 0 ; i < 300 ; i++) { pools.submit(runnable); } latch.await(); long end = System.currentTimeMillis(); System.out.println("总时间是:" + (end - begin)); }
2 优惠券秒杀下单 2.1 接口
考虑的点:
秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
库存是否充足,不足则无法下单
2.2 分析及代码实现
controller
1 2 3 4 5 6 7 8 9 10 11 @RestController @RequestMapping("/voucher-order") public class VoucherOrderController { @Autowired private VoucherOrderServiceImpl voucherOrderService; @PostMapping("seckill/{id}") public Result seckillVoucher (@PathVariable("id") Long voucherId) { return voucherOrderService.seckillVoucher(voucherId); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 @Service public class VoucherOrderServiceImpl extends ServiceImpl <VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Autowired private ISeckillVoucherService iSeckillVoucherService; @Autowired private RedisWorker redisWorker; @Override @Transactional public Result seckillVoucher (Long voucherId) { SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId); LocalDateTime beginTime = seckillVoucher.getBeginTime(); LocalDateTime endTime = seckillVoucher.getEndTime(); LocalDateTime now = LocalDateTime.now(); if (now.isBefore(beginTime)) { return Result.fail("秒杀尚未开始,请耐心等待!秒杀开始时间:" + beginTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss" ))); } if (now.isAfter(endTime)) { return Result.fail("秒杀已经结束,感谢支持!" ); } Integer stock = seckillVoucher.getStock(); if (stock <= 0 ) { return Result.fail("商品已经售罄!" ); } boolean result = iSeckillVoucherService.update() .setSql("stock = stock -1" ) .eq("voucher_id" , voucherId).update(); if (!result) { return Result.fail("商品已经售罄!" ); } long orderId = redisWorker.nextID("order" ); VoucherOrder voucherOrder = new VoucherOrder (); voucherOrder.setId(orderId); voucherOrder.setVoucherId(voucherId); voucherOrder.setUserId(UserHolder.getUser().getId()); save(voucherOrder); return Result.ok(orderId); } }
3 超卖问题 3.1 背景 如果只有同一时间只有一个用户下单没有任何的问题,但是此时如果统一时间有多个用户下单,QPS较高的情况此时就会出现并发的问题
3.2 超卖模拟 将库存恢复(tb_seckill_voucher),清空订单数据(tb_voucher_order),使用Jemeter
3.3 原因分析 当库存还有1的时候,线程1查询库存的时候,得到库存为大于0,正准备扣减库存,此时正好线程2过来也进行查询,库存也是大于0,线程2同样准备扣减库存,当两个线程结束的时候,库存被-2了。
3.4 锁
3.4.1 悲观锁 悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等。
3.4.2 乐观锁 乐观锁:增加一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功。
这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过。
当然乐观锁还有一些变种的处理方式比如cas:compare and set
3.5 解决 3.5.1 方案一
根据乐观锁分析,只需要比对和上次的version相同即可,如果版本相同那么证明是没有被修改过,此时扣减库存,同时version++,如果不同,那么证明已经被修改了。
但是发现库存和版本号同样也可以做到这个功能,因此无需多增加版本信息。
1 2 3 4 5 6 7 8 boolean result = iSeckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .eq("stock" , stock) .update();
即:比对库存是否相同即可。
问题:有很多失败的,售卖出去的很少,没有实现超卖
原因:这样修改只保证了同一时间查询出来的库存相同的时候,多个线程只有一个会成功,其他的都会失败。
3.5.2 方案二 针对方案1的问题,只需要将库存设置为大于0即可,不需要严格的按照cas去做
1 2 3 4 5 6 7 boolean result = iSeckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .gt("stock" , 0 ) .update();
4 一人一单 4.1 背景 在优惠券不超卖的情况下需要考虑一个问题:每个用户只能领取一个优惠券,不能多次重复的领取
4.2 修改 增加对优惠券id和用户id进行判断
1 2 3 4 5 6 7 8 9 Long userID = UserHolder.getUser().getId();Integer count = query().eq("user_id" , userID).eq("voucher_id" , voucherId).count();if (count > 0 ) { return Result.fail("秒杀优惠券每人限购1张,感谢配合,本优惠券最终解释权归ty公司所有!" ); }
Jemeter测试:
4.3 分析 此时问题出现在了查询至修改库存的地方,多线程过来之后,查询到都不存在订单,因此某几个线程还是会一起去创建订单
4.4 解决 使用悲观锁进行解决
对代码的分析,此时只需要对创建订单这个方法进行抽取,使用sychronized加锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 @Transactional public synchronized Result createVoucherOrder (Long voucherId) { Long userID = UserHolder.getUser().getId(); Integer count = query().eq("user_id" , userID).eq("voucher_id" , voucherId).count(); if (count > 0 ) { return Result.fail("秒杀优惠券每人限购1张,感谢配合,本优惠券最终解释权归ty公司所有!" ); } boolean result = iSeckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .gt("stock" , 0 ) .update(); if (!result) { return Result.fail("商品已经售罄!" ); } long orderId = redisWorker.nextID("order" ); VoucherOrder voucherOrder = new VoucherOrder (); voucherOrder.setId(orderId); voucherOrder.setVoucherId(voucherId); voucherOrder.setUserId(userID); save(voucherOrder); return Result.ok(orderId); }
4.4.1 问题一 此时sychronized加锁的话,锁粒度很粗,直接锁住的是整个方法,导致性能较差,因此考虑缩小锁的范围
此时使用用户ID作为锁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 @Transactional public Result createVoucherOrder (Long voucherId) { Long userID = UserHolder.getUser().getId(); synchronized (userID.toString().intern()) { Integer count = query().eq("user_id" , userID).eq("voucher_id" , voucherId).count(); if (count > 0 ) { return Result.fail("秒杀优惠券每人限购1张,感谢配合,本优惠券最终解释权归ty公司所有!" ); } boolean result = iSeckillVoucherService.update() .setSql("stock = stock - 1" ) .eq("voucher_id" , voucherId) .gt("stock" , 0 ) .update(); if (!result) { return Result.fail("商品已经售罄!" ); } long orderId = redisWorker.nextID("order" ); VoucherOrder voucherOrder = new VoucherOrder (); voucherOrder.setId(orderId); voucherOrder.setVoucherId(voucherId); voucherOrder.setUserId(userID); save(voucherOrder); return Result.ok(orderId); } }
4.4.2 问题二 当前方法加了@Transactional进行事务控制,在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,因此需要将方法整体包起来,确保事务不会出现问题
1 2 3 4 5 Long userID = UserHolder.getUser().getId();synchronized (userID.toString().intern()) { return createVoucherOrder(voucherId); }
4.4.3 问题三 此时事务的标志是在createVocherOrder方法上,并没有在seckillVoucher方法上,在调用createVocherOrder的时候使用的this方式里调用的,因此是VoucherOrderServiceImpl而不是VoucherOrderService的代理对象。因此我们需要获得原始的事务对象, 来操作事务。
VoucherOrderServiceImpl.java
1 2 3 4 5 6 7 8 Long userID = UserHolder.getUser().getId();synchronized (userID.toString().intern()) { Object o = AopContext.currentProxy(); IVoucherOrderService proxy = (IVoucherOrderService) o; return proxy.createVoucherOrder(voucherId); }
IVoucherOrderService.java
1 2 3 4 5 6 7 public interface IVoucherOrderService extends IService <VoucherOrder> { Result seckillVoucher (Long voucherId) ; Result createVoucherOrder (Long voucherId) ; }
pom依赖
1 2 3 4 5 6 <!-- Spring事务失效,采用AopContext API来处理 --> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
HmDianPingApplication.java
1 @EnableAspectJAutoProxy(exposeProxy = true)
5 集群模式下的一人一单 通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
我们将服务启动两份,端口分别为8081和8082:
重新指定新服务端口
debug启动
nginx修改
重启nginx
访问接口(http://localhost:8080/api/voucher/list/1),直至两个服务都有sql语句输出,这样模拟集群就生效了
使用postman配置2个http请求
5.1 问题 此时使用Postman发送完成后
此时发现2个请求在查询的时候count都是0,那么说明都是可以下单的,此时说明这个是有问题的。
5.2 分析 此时启动的另外一个服务,相当于是新一个JVM。如果在同一个应用下,JVM只有一个,此时锁监视器也只有一个,因此是锁可以实现互斥的,但是现在存在两个JVM,锁监视器在每个JVM中存在一个,因此无法实现互斥