黑马点评-day03-秒杀笔记

张开发
2026/4/19 2:24:52 15 分钟阅读

分享文章

黑马点评-day03-秒杀笔记
全局ID生成器核心整理一、定义全局ID生成器是分布式系统下用于生成全局唯一ID的工具为分布式环境下的数据提供唯一标识避免多节点数据ID冲突。二、核心特性特性含义作用/意义唯一性✅生成的ID在整个分布式系统中绝对不重复保证数据在全系统内可唯一识别避免主键冲突、数据混乱高可用⚙️服务稳定可靠故障时仍能正常生成ID保障分布式系统不因为ID生成器故障而整体不可用高性能⚡生成速度快、并发能力强支撑高并发场景如秒杀、订单系统下的大量ID生成需求递增性ID整体保持递增趋势不一定严格连续有利于数据库创建索引提升插入和查询性能避免页分裂安全性ID不易被猜测、泄露避免业务信息被枚举保护业务数据安全防止恶意爬取或业务逻辑被推测三、补充说明递增性细节不需要严格连续递增只要整体趋势向上即可如雪花算法的时间戳部分保证递增这样既满足数据库索引优化又避免ID泄露业务量。核心地位全局唯一ID是分布式系统的基础组件广泛应用于订单号、流水号、主键ID等场景。四、一句话记忆全局ID生成器 分布式环境下生成唯一、高可用、高性能、递增、安全的全局唯一标识工具。ComponentpublicclassRedisIdWorker{privatestaticfinalintCOUNT_BITS32;//序列号的位数privatefinalStringRedisTemplatestringRedisTemplate;publicRedisIdWorker(StringRedisTemplatestringRedisTemplate){this.stringRedisTemplatestringRedisTemplate;}//不同业务用不同自增长用前缀区分publiclongnextId(StringkeyPrefix){//1.生成时间戳LocalDateTimenowLocalDateTime.now();longnowSecondnow.toEpochSecond(ZoneOffset.UTC);longtimestampnowSecond-BEGIN_TIMESTAMP;//2.生成序列号//2.1获取当前日期精确到天Stringdatenow.format(DateTimeFormatter.ofPattern(yyyy:MM:dd));//2.2自增长longcountstringRedisTemplate.opsForValue().increment(icr:keyPrefixdate);//3.拼接并返回returntimestampCOUNT_BITS|count;}}悲观锁解决「一人一单」问题防止同一个用户重复下单乐观锁解决「超卖」问题防止库存扣成负数OverridepublicResultseckillVoucher(LongvoucherId){//1.查询优惠券信息SeckillVouchervoucherseckillService.getById(voucherId);//2.判断秒杀是否开始LocalDateTimebeginvoucher.getBeginTime();if(begin.isAfter(LocalDateTime.now())){//尚未开始returnResult.fail(秒杀尚未开始);}//3.判断秒杀是否结束if(voucher.getEndTime().isBefore(LocalDateTime.now())){returnResult.fail(秒杀已结束);}//4.判断库存是否充足if(voucher.getStock()1){//库存不足returnResult.fail(库存不足);}//5.一人一单LonguserIdUserHolder.getUser().getId();//保证事务提交存入db后再释放锁才能确保线程安全synchronized(userId.toString().intern()){IVoucherOrderServiceproxy(IVoucherOrderService)AopContext.currentProxy();returnproxy.creatVoucherOrder(voucherId);}}TransactionalpublicResultcreatVoucherOrder(Long voucherId){//这里是包装类对象这里需要的是值一样toString返回的是地址,// intern去字符串常量池里找如果池中已经有 1001 → 直接返回池中的对象如果没有 → 把 1001 放进池再返回//这使得同一个用户不管new多少对象//不同用户就不会被锁定//5.一人一单Long userIdUserHolder.getUser().getId();//5.1查询订单intcountquery().eq(user_id,userId).eq(voucher_id,voucherId).count();//5.2判断是否存在if(count0){//用户购买过了returnResult.fail(您已经购买过一次了);}//6.扣减库存boolean successseckillService.update().setSql(stock stock - 1).eq(voucher_id,voucherId).gt(stock,0).update();// ✅ 核心只要库存大于0就可以扣.update(); //乐观锁cas法//6.1库存不足if(!success){// 扣减失败returnResult.fail(库存不足);}//6.创建订单VoucherOrder voucherOrdernewVoucherOrder();voucherOrder.setVoucherId(voucherId);// Long userId UserHolder.getUser().getId();voucherOrder.setUserId(userId);Long orderIdredisIdWorker.nextId(order);voucherOrder.setId(orderId);//订单id(主键), 由id生成器生成。voucherOrderService.save(voucherOrder);//返回订单idreturnResult.ok(orderId);}秒杀业务核心知识点整理1. 实体类注解详解TableId主键注解代码示例TableId(valuevoucher_id,typeIdType.INPUT)privateLongvoucherId;详细解读作用标识该字段为数据库主键。value voucher_id指定数据库表中的主键列名为voucher_id对应实体字段voucherId。type IdType.INPUT关键点。表示主键值由开发者手动设置Input。MyBatis-Plus不会自动生成 ID非自增。应用场景秒杀优惠券 ID 需要使用全局 ID 生成器如雪花算法生成因此必须使用INPUT类型。EqualsAndHashCode注解代码entity类示例EqualsAndHashCode(callSuperfalse)详细解读作用重写equals()和hashCode()方法让对象比较基于内容而非内存地址。业务意义不加此注解Java 默认比较地址两个new出来的对象即使数据相同也不相等。加此注解只要业务关键字段如 ID、库存等相同即视为同一个对象。这对于将对象放入HashMap、HashSet或进行去重判断至关重要。callSuper false因为该类未继承父类无需调用父类的equals和hashCode。设置为false仅比较当前类字段设置为true反而会报错或逻辑错误。2. MyBatis-Plus (MP) 操作技巧Wrapper 构造器的简化写法MP 提供了query()和update()方法内部封装了new QueryWrapper()和new UpdateWrapper()旨在简化代码。对比示例原写法繁琐UpdateWrapperSeckillVoucherwrappernewUpdateWrapper();wrapper.setSql(stock stock - 1).eq(voucher_id,voucherId);seckillService.update(wrapper);简化写法推荐seckillService.update().setSql(stock stock - 1).eq(voucher_id,voucherId).update();// 链式调用最后执行 update3. 并发安全与锁策略乐观锁解决超卖问题核心思路CAS (Compare And Swap)即更新时检查数据是否被修改。方案演进CAS 严格版本不推荐.eq(stock,voucher.getStock())原理要求数据库当前库存必须等于查询出来的旧值。问题虽然解决了超卖但成功率极低。高并发下一旦库存被其他线程修改当前线程就会失败即使库存充足。CAS 优化版本推荐booleansuccessseckillService.update().setSql(stock stock - 1).eq(voucher_id,voucherId).gt(stock,0)// 核心优化只要库存 0 就允许扣减.update();原理不关心库存是否变化只关心当前是否还有库存。SQL 翻译UPDATE table SET stock stock - 1 WHERE voucher_id ? AND stock 0。效果既保证了不超卖stock 0又大幅提高了并发成功率。为什么扣减后还要判断库存不足代码逻辑// 步骤 4判断库存查询if(voucher.getStock()1){returnResult.fail(库存不足);}// ... 中间可能穿插其他业务逻辑如一人一单校验...// 步骤 6扣减库存更新booleansuccessseckillService.update()...update();if(!success){returnResult.fail(库存不足);}// 为什么还要判断原因解析非原子操作查询Step 4和更新Step 6之间存在时间差。并发场景线程 A 和线程 B 同时通过 Step 4此时库存1。线程 A 扣减成功库存变 0。线程 B 再去扣减时数据库已无库存。结论Step 4 用于性能优化提前拦截大部分请求Step 6 的判断才是数据安全的最终防线。4. “一人一单” 并发安全问题业务逻辑与隐患需求同一个用户对同一个优惠券只能购买一次。代码逻辑intcountquery().eq(user_id,userId).eq(voucher_id,voucherId).count();if(count0){returnResult.fail(...);}为什么必须两个条件 (user_idvoucher_id)只用user_id用户买过 A 券就永远不能买 B 券错误。只用voucher_id只要有一个人买了所有人都不能买错误。联合判断锁定“用户”与“优惠券”的关系确保唯一性。并发隐患高并发下两个线程可能同时查询到count 0随后都去下单导致“一人多单”。这和超卖问题本质一样查询与更新非原子性。5. 事务与锁的终极解决方案核心问题事务失效在类内部直接调用this.creatVoucherOrder()即使方法上有Transactional事务也不会生效。原因Spring 事务基于AOP 代理实现。this指的是目标对象本身而非代理对象未经过 AOP 增强逻辑。解决方案AOP 上下文获取代理关键代码// 1. 开启 AOP 代理暴露启动类或配置类EnableAspectJAutoProxy(exposeProxytrue)// 2. 在业务逻辑中获取代理对象synchronized(userId.toString().intern()){// 获取当前类的代理对象IVoucherOrderServiceproxy(IVoucherOrderService)AopContext.currentProxy();// 通过代理对象调用事务方法returnproxy.creatVoucherOrder(voucherId);}以下是整理后的 Markdown 笔记并发锁细节为什么使用userId.toString().intern()在实现“一人一单”时加锁代码如下synchronized(userId.toString().intern()){...}之所以不直接锁userId而是要转字符串并调用intern()核心逻辑如下1. 锁对象的本质synchronized锁的是对象的内存地址而不是对象的值。userId是Long类型包装类每次获取都是new出来的新对象。如果直接synchronized(userId)即使 ID 值相同内存地址也不同导致锁失效各锁各的起不到互斥作用。2.toString()的作用将Long对象转换为字符串对象例如100L-100。虽然转成了字符串但如果直接new String地址依然可能不同。3.intern()的核心作用/为什么不锁key?关键作用强制在【字符串常量池】中查找或创建字符串。机制如果常量池中已有该字符串如100直接返回池中的对象。如果没有将字符串放入池中并返回该对象。结果无论代码运行多少次只要是同一个用户 ID最终拿到的都是常量池中同一个对象。4. 最终效果同一个用户 ID拿到的是同一个字符串对象 →锁得住线程排队执行。不同用户 ID拿到的是不同的字符串对象 →互不干扰并行执行。总结这就实现了用户级别的细粒度锁既保证了同一用户的线程安全又避免了锁整个方法导致所有用户排队最大化了并发性能。我给你整理成纯错误总结 错误代码示范专门给你复习背的只讲错的不讲对的错误代码示范事务包锁有并发漏洞TransactionalpublicResultcreateVoucherOrder(LongvoucherId){LonguserIdUserHolder.getUser().getId();// 锁写在 Transactional 方法内部synchronized(userId.toString().intern()){// 1. 判断一人一单intcountquery().eq(user_id,userId).eq(voucher_id,voucherId).count();if(count0){returnResult.fail(用户已经购买过一次);}// 2. 扣库存booleansuccessseckillVoucherService.update().setSql(stock stock - 1).eq(voucher_id,voucherId).gt(stock,0).update();if(!success){returnResult.fail(库存不足);}// 3. 创建订单VoucherOrdervoucherOrdernewVoucherOrder();// ... 设置订单信息 ...save(voucherOrder);returnResult.ok(orderId);}}这段错误代码的问题总结锁的位置错误synchronized写在了Transactional方法内部属于事务包锁。执行顺序问题先开启事务加锁、执行业务逻辑释放锁方法结束后才提交事务核心漏洞锁释放了但事务还没提交。这中间存在一个极短的时间窗口。线程安全风险锁释放后下一个线程可以立刻进入但事务未提交数据库里订单/库存数据还没更新新线程查询不到订单会重复下单、超卖。根本原因锁释放时机 早于 事务提交时机导致并发安全失效。一句话记忆复习专用锁写在 Transactional 方法内部 → 锁释放早于事务提交 → 出现并发安全问题。为什么必须“锁 代理对象调用”执行顺序至关重要加锁(synchronized)防止并发线程同时进入。开启事务(proxy.create...)通过代理对象进入事务逻辑。执行业务查单、扣库存、下单。提交事务将数据写入数据库。释放锁。如果不用代理事务失效锁释放了但事务没提交。下一个线程进来查不到上一个人的订单因为还在内存中未落库导致并发安全问题。知识点串联总结AOP面向切面编程用于动态增强方法如添加事务。代理对象AOP 生成的“包装壳”内部包含了事务开启、提交、回滚的逻辑。thisvsproxythis.method()直接调用无事务。proxy.method()代理调用有事务。userId.toString().intern()intern()确保相同 ID 的字符串常量池对象唯一从而保证锁粒度精确到“用户级别”。一句话总结Spring 事务依赖代理对象生效为了保证线程安全锁必须包裹住整个事务过程加锁 - 事务提交 - 释放锁。问题发现userId.toString().intern()是在同一个jvm中的常量池中同一个对象获取的锁才是相同的。如果是在集群环境下由于部署了多个tomcat每个tomcat都有一个属于自己的jvm同一个用户在不同jvm获取的锁也就是不同的了。解决方案欢迎大家看下一篇文章

更多文章