各店舗がクーポンを発行できる:
#ユーザーが急いで購入すると、注文が生成され、tb_voucher_order テーブルに保存されます。ただし、注文テーブルでデータベースの自動インクリメント ID が使用されている場合、いくつかの問題が発生します。create table tb_voucher_order ( id bigint not null comment '主键' primary key, user_id bigint unsigned not null comment '下单的用户id', voucher_id bigint unsigned not null comment '购买的代金券id', pay_type tinyint(1) unsigned default 1 not null comment '支付方式 1:余额支付;2:支付宝;3:微信', status tinyint(1) unsigned default 1 not null comment '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款', create_time timestamp default CURRENT_TIMESTAMP not null comment '下单时间', pay_time timestamp null comment '支付时间', use_time timestamp null comment '核销时间', refund_time timestamp null comment '退款时间', update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间' );グローバル ID ジェネレーター
は、分散システムでグローバルに一意の ID を生成するために使用されるツールです。通常、次の条件を満たす必要があります。特徴:
#ID のセキュリティを高めるため、Redis によって自動的にインクリメントされる値を直接使用することはできませんが、他の情報を結合します:
D コンポーネント:
符号ビット: 1 ビット、常に 0、正の数を示します# シリアル番号: 32 ビット、秒以内のカウンタ、1 秒あたり 2^32 の異なる ID をサポート
@Component public class RedisIdWorker { /** * 开始时间戳,以2022.1.1为基准计算时间差 */ private static final long BEGIN_TIMESTAMP = 1640995200L; /** * 序列号的位数 */ private static final int COUNT_BITS = 32; private StringRedisTemplate stringRedisTemplate; public RedisIdWorker(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } /** * 生成带有业务前缀的redis自增id * @param keyPrefix * @return */ 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.拼接并返回 // 用于是数字类型的拼接,所以不能像拼接字符串那样处理,而是通过位运算将高32位存 符号位+时间戳,低32位存 序列号 return timestamp << COUNT_BITS | count; } public static void main(String[] args) { LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0); long second = time.toEpochSecond(ZoneOffset.UTC); System.out.println(second);// 1640995200 } }
グローバル ID ジェネレーターをテストします:
@SpringBootTest class HmDianPingApplicationTests { @Resource private RedisIdWorker redisIdWorker; private ExecutorService executorService = Executors.newFixedThreadPool(500); @Test void testIdWorker() throws InterruptedException { CountDownLatch latch = new CountDownLatch(300); // 每个线程生成100个id Runnable task = () -> { for (int i = 0; i < 100; i++) { long id = redisIdWorker.nextId("order"); System.out.println("id = " + id); } latch.countDown(); }; // 300个线程 long begin = System.currentTimeMillis(); for (int i = 0; i < 300; i++) { executorService.submit(task); } latch.await(); long end = System.currentTimeMillis(); System.out.println("time = " + (end - begin)); } }
テスト結果:
2. グローバル一意 ID 生成戦略
Redis の自己インクリメント
スノーフレーク アルゴリズム (スノーフレーク)
データベースの自己インクリメント (自己インクリメント ID を保存する別のテーブルを構築し、データベースのシャーディングとテーブルのシャーディング後にテーブルに割り当てます)
3. Redis の自動インクリメント ID 戦略
自己増加IDの構造: タイムスタンプカウンター
2. クーポンフラッシュセール注文の実装
クーポン テーブル情報:
tb_voucher: クーポン Basic情報、割引額、利用ルールなど(tb_voucherテーブルのtypeフィールドで通常クーポンかフラッシュセールクーポンかを区別)create table tb_voucher ( id bigint unsigned auto_increment comment '主键' primary key, shop_id bigint unsigned null comment '商铺id', title varchar(255) not null comment '代金券标题', sub_title varchar(255) null comment '副标题', rules varchar(1024) null comment '使用规则', pay_value bigint(10) unsigned not null comment '支付金额,单位是分。例如200代表2元', actual_value bigint(10) not null comment '抵扣金额,单位是分。例如200代表2元', type tinyint(1) unsigned default 0 not null comment '0,普通券;1,秒杀券', status tinyint(1) unsigned default 1 not null comment '1,上架; 2,下架; 3,过期', create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间', update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间' );
create table tb_seckill_voucher ( voucher_id bigint unsigned not null comment '关联的优惠券的id' primary key, stock int(8) not null comment '库存', create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间', begin_time timestamp default CURRENT_TIMESTAMP not null comment '生效时间', end_time timestamp default CURRENT_TIMESTAMP not null comment '失效时间', update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间' ) comment '秒杀优惠券表,与优惠券是一对一关系';
@RestController @RequestMapping("/voucher") public class VoucherController { @Resource private IVoucherService voucherService; /** * 新增秒杀券 * @param voucher 优惠券信息,包含秒杀信息 * @return 优惠券id */ @PostMapping("seckill") public Result addSeckillVoucher(@RequestBody Voucher voucher) { voucherService.addSeckillVoucher(voucher); return Result.ok(voucher.getId()); } }
@Service public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService { @Resource private ISeckillVoucherService seckillVoucherService; @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); } }
テストの追加:
#テスト結果:
##3.フラッシュセール注文の実施
注文時には次の 2 点を判断する必要があります。フラッシュセールが開始または終了しました。開始していないか終了している場合は注文できません在庫が足りているか、不足している場合は注文できません
@RestController @RequestMapping("/voucher-order") public class VoucherOrderController { @Resource private IVoucherOrderService voucherOrderService; @PostMapping("seckill/{id}") public Result seckillVoucher(@PathVariable("id") Long voucherId) { return voucherOrderService.seckillVoucher(voucherId); } }
@Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Override public 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 long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); // 6.2.用户id Long userId = UserHolder.getUser().getId(); voucherOrder.setUserId(userId); // 6.3.代金券id voucherOrder.setVoucherId(voucherId); save(voucherOrder); return Result.ok(orderId); } }
#在庫控除成功:
4. 売られすぎの問題在庫数が多い場合、リクエストが同時にアクセスされると、売られすぎの問題が発生します
#
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
(1)版本号法
(2)CAS法
用库存代替了版本号,可以少加一个字段
扣库存时,与查询时的库存比较,没被修改则可以扣减库存
乐观锁方式,通过CAS判断前后库存是否一致,解决超卖问题:
// 之前的代码 boolean success = seckillVoucherService.update() .setSql("stock= stock -1") .eq("voucher_id", voucherId).update(); // 乐观锁方式,通过CAS判断前后库存是否一致,解决超卖问题 boolean success = seckillVoucherService.update() .setSql("stock= stock -1") // set stock = stock -1 .eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); // where id = ? and stock = ?
又出现新的问题:
假设100个线程同时请求,但通过CAS判断后,只有一个线程能扣减库存成功,其余99个线程全部失败
此时,库存剩余99,但是实际业务可以满足其余99个线程扣减库存
虽然能解决超卖问题,但是设计不合理
所以为了解决失败率高的问题,需要进一步改进:
通过CAS 不再 判断前后库存是否一致,而是判断库存是否大于0
boolean success = seckillVoucherService.update() .setSql("stock= stock -1") .eq("voucher_id", voucherId).gt("stock",0).update(); // where id = ? and stock > 0
超卖这样的线程安全问题,解决方案有哪些?
(1)悲观锁:添加同步锁,让线程串行执行
优点:简单粗暴
缺点:性能一般
(2)乐观锁:不加锁,在更新时判断是否有其它线程在修改
优点:性能相对悲观锁好(但是仍然需要同时查数据库,影响性能)
缺点:存在成功率低的问题(可以采用分段锁方式提高成功率)
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
在扣减库存之前,加上一人一单的逻辑:
// 5.一人一单逻辑 Long userId = UserHolder.getUser().getId(); // 5.1.查询订单数量 int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count(); // 5.2.判断是否下过单 if (count > 0) { // 用户已经购买过了 return Result.fail("用户已经购买过一次!"); }
此处仍会出现并发问题,当同一用户模拟大量请求同时查询是否下过单时,如果正好都查询出count为0,就会跳过判断继续执行扣减库存的逻辑,此时就会出现一人下多单的问题
解决方法:
由于是判断查询的数据是否存在,而不是像之前判断查询的数据是否修改过
所以这里只能加悲观锁
首先将一人一单之后的逻辑全部加锁,所以将一人一单之后的逻辑抽取出一个方法进行加锁,public Result createVoucherOrder(Long voucherId)
如果直接在方法上加锁,则锁的是this对象,锁的对象粒度过大,就算是不同的人执行都会阻塞住,影响性能,public synchronized Result createVoucherOrder(Long voucherId)
所以将锁的对象改为userId,但是不能直接使用synchronized (userId),因为每次执行Long userId = UserHolder.getUser().getId();虽然值一样,但是对象不同,因此需要这样加锁 synchronized (userId.toString().intern()),intern()表示每次从字符串常量池中获取,这样值相同时,对象也相同
为了防止事务还没提交就释放锁的问题,则不能将锁加在createVoucherOrder方法内部,例如:
@Transactional public Result createVoucherOrder(Long voucherId) { synchronized (userId.toString().intern()) { 。。。 } }
而是需要等事务提交完再释放锁,例如:
synchronized (userId.toString().intern()) { // 获取代理对象(事务) IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); }
由于只有一人一单之后的逻辑涉及到修改数据库,所以只需对该方法加事务
@Transactional
public Result createVoucherOrder(Long voucherId)
由于只对createVoucherOrder方法加了事务,而该方法是在seckillVoucher方法中被调用,seckillVoucher方法又没有加事务,为了防止事务失效,则不能直接在seckillVoucher方法调用createVoucherOrder方法,例如:
@Override public Result seckillVoucher(Long voucherId) { 。。。。 synchronized (userId.toString().intern()) { return this.createVoucherOrder(voucherId); } }
而是需要通过代理对象调用createVoucherOrder方法,因为@Transactional事务注解的原理是通过获取代理对象执行目标对象的方法,进行AOP操作,所以需要这样:
@Override public Result seckillVoucher(Long voucherId) { 。。。。 // 获取代理对象(事务) IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); }
并且还要引入依赖:
<dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
还要开启注解暴露出代理对象:
@EnableAspectJAutoProxy(exposeProxy = true) @MapperScan("com.hmdp.mapper") @SpringBootApplication public class HmDianPingApplication { public static void main(String[] args) { SpringApplication.run(HmDianPingApplication.class, args); } }
完整VoucherOrderServiceImpl代码:
@Service public class VoucherOrderServiceImpl extends ServiceImplimplements IVoucherOrderService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Override public 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("库存不足!"); } Long userId = UserHolder.getUser().getId(); synchronized (userId.toString().intern()) { // 获取代理对象(事务) IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); return proxy.createVoucherOrder(voucherId); } } @Transactional public Result createVoucherOrder(Long voucherId) { // 5.一人一单逻辑 Long userId = UserHolder.getUser().getId(); // 5.1.查询订单数量 int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count(); // 5.2.判断是否下过单 if (count > 0) { // 用户已经购买过了 return Result.fail("用户已经购买过一次!"); } // 6,扣减库存 // 乐观锁方式,通过CAS判断库存是否大于0,解决超卖问题: boolean success = seckillVoucherService.update() .setSql("stock= stock -1") .eq("voucher_id", voucherId).gt("stock",0).update(); // where id = ? and stock > 0 if (!success) { // 扣减库存失败 return Result.fail("库存不足!"); } // 7.创建订单 VoucherOrder voucherOrder = new VoucherOrder(); // 7.1.订单id long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); // 7.2.用户id voucherOrder.setUserId(userId); // 7.3.代金券id voucherOrder.setVoucherId(voucherId); save(voucherOrder); // 8.返回订单id return Result.ok(orderId); } }
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
我们将服务启动两份,端口分别为8081和8082:
然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:
修改完后,重新加载nginx配置文件:
现在,用户请求会在这两个节点上负载均衡,再次测试下是否存在线程安全问题:
访问8081端口的线程进入了synchronized中
访问8082端口的线程也进入了synchronized中
最终同一个用户下了2单扣了2个库存,所以在集群模式下,出现了一人多单的问题:
分析:
锁的原理是每个JVM中都有一个Monitor作为锁对象,所以当对象相同时,获取的就是同一把锁
但是不同的JVM中的Monitor不同,所以获取的不是同一把锁
因此集群模式下,加synchronized锁也会出现并发安全问题,需要加分布式锁
以上がRedisクーポンフラッシュセール機能の実装方法の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。