🍃
Day09-Day12业务 优惠劵管理,领取,兑换,使用及优化 涉及lua脚本与Redisson分布式锁,异步领劵,事务边界问题,乐观锁,事务失效,超卖与刷卷问题
Redis中使用lua要点
在业务与lua中日期统一使用字符串/unix timestamp
- 由于Redis中lua在沙箱环境中执行,是没有像os之类的模块的,没法用os.time()来将LocalDateTime这一类转成的时间字符串解析成以秒s为单位的unix timestamp,故而不能直接与redis time指令返回的第一个参数,即以秒s为单位的unix timestamp比较,要么直接将format后的字符串同时与LocalDateTime.now()format后结果一起传入,以字典序比较,要么定好时区转为epochSecond传入与redis time指令返回首参比较。
- 转为unix timestamp 示例
java
/** * (lua版本工具方法)转换时间LocalDateTime为long类型unixTimeStamp */ private long convertDateTimeToEpochSecond(LocalDateTime time) { ZonedDateTime zonedDateTime = time.atZone(PromotionLuaConstants.ZONE_ID); return zonedDateTime.toEpochSecond(); }
Java
debug lua脚本
- redis中lua也是不能使用print打印消息的,这时可以连上本地测试redis,在lua中使用redis.log()打印到redis的日志文件中调试。
- 调试脚本,-a传入密码evalsha读取$中script load读取的lua脚本返回的哈希值,进而选定eval要执行的脚本,之后传参,key的数量,key值列表KEYS,参数列表ARGV。
bash
redis-cli -a '123321' EVALSHA $( redis-cli -a '123321' SCRIPT LOAD "$(cat /usr/local/etc/redis/receive_coupon.lua)" ) 2 prs:coupon:1631837658684608513 prs:user:coupon:1631837658684608513 129
Bash
- 需要注意的是lua里只有nil和false才算做false,其他均为true,所以下方脚本不能直接把EXISTS返回值当bool使用。
参阅:Programming in Lua : 2.2
Programming in Lua : 2.2
This first edition was written for Lua 5.0. While still largely relevant for later versions, there are some differences.The fourth edition targets Lua 5.3 and is available at Amazon and other bookstores.By buying the book, you also help to support the Lua project.
In Lua, any value may represent a condition. Conditionals (such as the ones in control structures) consider false and nil as false and anything else as true. Beware that, unlike some other scripting languages, Lua considers both zero and the empty string as true in conditional tests.
- receive_coupon.lua 领取优惠劵的lua脚本。
注:原视频里不提供使用lua的实现,给定的参考源代码仓中并不考虑redis没有数据(过期)的情况,此处为考虑redis数据不存在后返回查询数据库的对应脚本,可结合流程图理解。
- 善用EVAL确定redis命令返回的类型,以及通过log打印校验lua脚本逻辑
参考图(单句记得return)




暴露代理对象使内部调用下事务生效
情况说明与解决方案
- 对于非事务方法调用事务方法,由于此时内部调用为普通对象(this)的调用,不是代理对象的调用,会出现声明式事务失效的情况,需要获得代理对象,通过代理对象调用确保事务生效。
- 启动类添加注解,暴露代理对象。

java
@EnableAspectJAutoProxy(exposeProxy = true)
Java
- 未实现异步写时候的领劵代码,获取代理对象进行调用。
java
/** * 领取优惠劵 */ @Override public void receiveCoupon(Long id) { if (id == null) { return; } // * 优惠劵是否存在 Coupon coupon = couponService.getById(id); if (coupon == null) { throw new DbException("目标优惠劵不存在:" + id); } // * 判断发放时间 LocalDateTime now = LocalDateTime.now(); if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) { throw new BizIllegalException("优惠劵不在发放时间内"); } // * 判断库存 if (coupon.getIssueNum() >= coupon.getTotalNum()) { throw new BizIllegalException("优惠劵库存不足"); } // * 校验单人领取数量并更新卷已领取数并添加用户卷记录 // * 悲观锁解决单人刷单 // * Aspectj获取代理对象,重新激活声明式事务 String userIdString = UserContext.getUser().toString().intern(); // * 单机锁 // synchronized (userIdString) { // IUserCouponService userCouponService = (IUserCouponService) AopContext.currentProxy(); // userCouponService.checkAndCreateUserCoupon(coupon); // } // * 分布式锁 String key = PromotionConstants.COUPON_RECEIVE_REDIS_LOCK_PREFIX + userIdString; // boolean lock = redisLock.tryLock(key, 10, TimeUnit.SECONDS); // if (!lock) { // throw new BizIllegalException("领取优惠劵业务操作过于频繁"); // } // try { // IUserCouponService userCouponService = (IUserCouponService) AopContext.currentProxy(); // userCouponService.checkAndCreateUserCoupon(coupon); // } finally { // redisLock.unlock(key); // } RLock lock = redissonClient.getLock(key); // * 30s lock 10s 检查续期 重新为30s boolean result = lock.tryLock(); if (!result) { throw new BizIllegalException("领取优惠劵业务操作过于频繁"); } try { IUserCouponService userCouponService = (IUserCouponService) AopContext.currentProxy(); userCouponService.checkAndCreateUserCoupon(coupon); } finally { lock.unlock(); } } /** * 校验单人领取数量并更新卷已领取数并添加用户卷记录(工具方法) */ @Transactional @Override public void checkAndCreateUserCoupon(Coupon coupon) { // * 判断是否超出单人可领数量 Integer receivedNum = lambdaQuery() .eq(UserCoupon::getUserId, UserContext.getUser()) .eq(UserCoupon::getCouponId, coupon.getId()) .count(); if (receivedNum >= coupon.getUserLimit()) { throw new BizIllegalException("用户领取已达上限"); } // * 允许领取,优惠劵领取数+1 boolean success = couponService.lambdaUpdate() .eq(Coupon::getId, coupon.getId()) // * 乐观锁解决超卖 .lt(Coupon::getIssueNum, coupon.getTotalNum()) .setSql("issue_num = issue_num + 1") .update(); if (!success) { throw new DbException("优惠卷领取更新失败"); } // * 用户卷里加一条记录 UserCoupon userCoupon = new UserCoupon(); userCoupon.setUserId(UserContext.getUser()); userCoupon.setCouponId(coupon.getId()); // * 设置有效日期 LocalDateTime termBeginTime = coupon.getTermBeginTime(); LocalDateTime termEndTime = coupon.getTermEndTime(); // * 使用天数而不是日期范围 if (termBeginTime == null) { termBeginTime = LocalDateTime.now(); termEndTime = termBeginTime.plusDays(coupon.getTermDays()); } userCoupon.setTermBeginTime(termBeginTime); userCoupon.setTermEndTime(termEndTime); save(userCoupon); }
Java
事务边界与锁边界问题
- 仍参考上方代码,如果将内部调用的函数@Transaction注解移出到外部函数上,会出现事务的边界大于了锁边界。导致当下一个线程获取到锁时,可能出现上一线程还未提交的情况,进而读取到过期数据,使得个人刷卷问题尽管上了锁却仍无法被消除(线程A抢卷,限领1张,上锁,读取0过校验,加1条记录,放锁未提交,切换为线程B,上锁,读取0过校验,加1条记录,放锁提交,A提交,直接就是>1的数量)。
- 总结而言,一定要确保事务边界小于锁边界,先提交事务,再释放锁,就不会出现这样的问题。
定时开始与结束发放实现
- 假设分片广播为3个进程,每个做一页,5为页大小。
- 采用直接分页不能保证不重复,比如三个进程一并开始处理15条,第一个得到前5条,先完成,第二个本该读取(为where筛选后的数据)第5-10条,此时满足条件的只有10条了,但偏移量不变,实际处理的是本次的第5-10条,原来的第10-15条,即本该由第三个处理的数据,一旦第三个读取在第一个提交,就会出现重复更新。
解决方案
1.可以采取先对id进行MOD,之后再对数据做where筛选,可以辅以limit确保更新数量不会太多。
2.可以采用事务+两阶段提交,三个进程自己不成功就rollback,成功的时候尝试获取另外两个对应的成功标记,不然阻塞等待直至超时,之后直接提交(前提是业务处理时间远小于执行周期)。
代码参见下方Day09任务
兑换码算法
基本要求

- 唯一性
- UUID(128bit,超过50bit)
- 雪花(64bit,超过50bit)
- 自增(√)
- 数值转字符串
- Base32
Base32

- 明文50bit数据
- 密文10位base32编码结果
明文结构

- 计算过程



领取优惠劵的lua方案
乐观锁解决领劵中超卖与刷卷问题
流程图
- 核心思想:两次尝试执行lua脚本,中间返回值为不存在则查库获得数据,之后通过hsetnx保证仅写入redis一次,之后重试。
- 代码参见Day10练习

乐观锁与悲观锁的简易性能测试
预先说明
- 仅做粗略参考,并不严谨,一是并没有经过长时间的压测,二是悲观锁业务并不完全正确(Redis层面没有消除超卖)。
- 悲观锁版本中读取与处理Redis数据的流程并不原子,分布式锁仅仅是锁了用户id,防止刷卷情形下的个人超限,但并不防止超卖,数据库层面通过行锁实现了乐观锁(update里加where判断已发放<总数),故而数据库层面没有问题,但redis层面却不能避开超卖,使得用户领劵的redis数据不能直接用于判断用户是否领到卷,可能出现100张限领1的卷被200个人抢到,每人都是1张,但不该有这么多人有卷。
参考指标
- 手动连测每次取第3次结果,2000并发,单个测3次。
悲观锁

乐观锁

兑换优惠劵的lua方案
- 此处是必然先查一次库的方案,仍是考虑了redis没有数据的情况。原视频没有考虑过不在Redis的情况(要求必须每次发布之后领取,不存在发布后长期可领致使Redis缓存失效的可能),参考源码中用bitmap判断是否已兑换,zset通过当前序列号找到在最大范围内可对应的有效最大序列号进而得到优惠劵id,但这同样缺乏对于缓存过期/内存淘汰的考虑,完全依赖redis必然有数据,不然直接返回错误。
- 考虑缓存失效的情况,bitmap判断兑换码是否用过是存在逻辑问题的,如果不查库直接走bitmap,不用EXISTS或SETNX之类的命令通过返回值判断有无,那么bitmap以8位一组扩容,当访问不存在的数据位时,通过序列号当偏移值去尝试SETBIT,会直接扩容把所有之前的未设置bit全设为0。
(如果第一次执行对5000序列号的数据SETBIT,下一次对于2000序列号的数据SETBIT,那么必然返回0,这个0事实上不能说明2000序列号的兑换码没有用过,必须查库才能确定,所以是无效的用法,必须上来先查库。)
- 进一步考虑,查库的时候已经获得了优惠劵id,已经不再需要原本的bitmap与zset来获取优惠劵id了,故而直接删去,此时对获得的优惠卷兑换码,由于只会使用一次,可以直接判断是否已经使用或过期。对于lua脚本,大体逻辑与直接领取相同,但需要部分修改。比如加上SETNX以兑换码为key,以用户id为value。(保障如果兑换过,要么可以在数据库里查询到,要么可以在缓存里查询到),确保一个较短时间内缓存里有领取信息,避免因为采用消息队列写导致可能出现的并发问题(如果没有SETNX,假设消息堆积,一个执行完后写消息未被消费,往后多个并发线程均读取到未过期未使用,会出现一码多次兑换)。
- 具体代码参见Day10练习实现
- 效果展示(多人一码,100人中仅57号成功兑换):


缓存效果:



插件分享
show comment,可以在每行代码处显示出对应注释的插件(不修改源文件)

参考效果(自动显示,并不修改文件内容)

@Async异步任务
注:会出现事务失效的问题(事务相关的数据保存在ThreadLocal里,@Async引出的新线程会使事务失效)
分布式锁,自定义AOP,Redisson,设计模式,SPEL
这一部分在视频里完全没提,第二版飞书文档的Day11里有部分讲解,以下做理论部分要点精简+代码部分分析。
分布式锁
- 引入背景:Synchronized单机锁基于JVM的Monitor实现,在集群下多个JVM意味着多个Monitor,无法达到互斥的效果,需要在多个实例外设置同一把锁,即分布式锁。
- Redis实现分布式锁的可能性:1.Redis可以被多JVM实例共享访问;2.SETNX互斥命令;3.DEL释放锁;4.单线程执行命令(串行)
- Redis直接实现分布式锁与可能遇到的问题:
- 直接实现:SET lock thread1 NX EX 20,主要包括两步
- 1.超时释放:锁不一定正常释放(实例宕机),可导致死锁,需要设置过期时间(例中上锁与超时设置操作保持原子性)
- 2.存入标识:存入自身线程标识,删除时如果仍是自身标识才可删除,防止锁误删(避免不了)(图源:
b11et3un53m.feishu.cn
)b11et3un53m.feishu.cn
- 1.超时问题:WatchDog机制,锁成功开定时任务,锁到期前自动续期避免超时释放,同时宕机后一同停止,避免死锁
- 2.原子性问题:Lua脚本
- 3.锁重入:类似Synchronized,可使用Hash记录持有者与重入次数,次数0时删除
- 4.主从同步延迟:RedLock
- 5.锁失败重试


但判断与删除不是原子的,仍可能误删

超时释放难以避免锁误删,锁的操作需要原子性,主从同步存在延迟,同一线程无法多次获取同一锁可能死锁,所以需要解决:
成熟解决方案:Redisson
Redisson Quick Start
maven引入
xml
<!--redisson--> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> </dependency>
XML
配置类与自动装配
java
package com.tianji.common.autoconfigure.redisson; import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.util.StrUtil; import com.tianji.common.autoconfigure.redisson.aspect.LockAspect; import lombok.extern.slf4j.Slf4j; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.data.redis.RedisProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.Duration; import java.util.ArrayList; import java.util.List; @Slf4j @ConditionalOnClass({RedissonClient.class, Redisson.class}) @Configuration @EnableConfigurationProperties(RedisProperties.class) public class RedissonConfig { private static final String REDIS_PROTOCOL_PREFIX = "redis://"; private static final String REDISS_PROTOCOL_PREFIX = "rediss://"; @Bean @ConditionalOnMissingBean public LockAspect lockAspect(RedissonClient redissonClient){ return new LockAspect(redissonClient); } @Bean @ConditionalOnMissingBean public RedissonClient redissonClient(RedisProperties properties){ log.debug("尝试初始化RedissonClient"); // 1.读取Redis配置 RedisProperties.Cluster cluster = properties.getCluster(); RedisProperties.Sentinel sentinel = properties.getSentinel(); String password = properties.getPassword(); int timeout = 3000; Duration d = properties.getTimeout(); if(d != null){ timeout = Long.valueOf(d.toMillis()).intValue(); } // 2.设置Redisson配置 Config config = new Config(); if(cluster != null && !CollectionUtil.isEmpty(cluster.getNodes())){ // 集群模式 config.useClusterServers() .addNodeAddress(convert(cluster.getNodes())) .setConnectTimeout(timeout) .setPassword(password); }else if(sentinel != null && !StrUtil.isEmpty(sentinel.getMaster())){ // 哨兵模式 config.useSentinelServers() .setMasterName(sentinel.getMaster()) .addSentinelAddress(convert(sentinel.getNodes())) .setConnectTimeout(timeout) .setDatabase(0) .setPassword(password); }else{ // 单机模式 config.useSingleServer() .setAddress(String.format("redis://%s:%d", properties.getHost(), properties.getPort())) .setConnectTimeout(timeout) .setDatabase(0) .setPassword(password); } // 3.创建Redisson客户端 return Redisson.create(config); } private String[] convert(List<String> nodesObject) { List<String> nodes = new ArrayList<>(nodesObject.size()); for (String node : nodesObject) { if (!node.startsWith(REDIS_PROTOCOL_PREFIX) && !node.startsWith(REDISS_PROTOCOL_PREFIX)) { nodes.add(REDIS_PROTOCOL_PREFIX + node); } else { nodes.add(node); } } return nodes.toArray(new String[0]); } }
Java
ConditionalOnClass自动装配,引入Redisson依赖时配置才生效
入参的RedisProperties来源:


resources/META-INF/spring.factories:
xml
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.tianji.common.autoconfigure.mq.MqConfig,\ com.tianji.common.autoconfigure.mvc.JsonConfig,\ com.tianji.common.autoconfigure.mvc.MvcConfig,\ com.tianji.common.autoconfigure.mvc.ParamCheckerConfig,\ com.tianji.common.autoconfigure.mybatis.MybatisConfig,\ com.tianji.common.autoconfigure.redisson.RedissonConfig,\ com.tianji.common.autoconfigure.swagger.Knife4jConfiguration, \ com.tianji.common.autoconfigure.xxljob.XxlJobConfig
XML
基本使用
java
@Autowired private RedissonClient redissonClient; @Test void testRedisson() throws InterruptedException { // 1.获取锁对象,指定锁名称 RLock lock = redissonClient.getLock("anyLock"); try { // 2.尝试获取锁,参数:waitTime、leaseTime、时间单位 boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS); if (!isLock) { // 获取锁失败处理 .. } else { // 获取锁成功处理 } } finally { // 4.释放锁 lock.unlock(); } }
Java
场景实例
java
/** * 领取优惠劵(分布式锁)悲观锁版本(数据库正常,但Redis超卖并且消息队列可能堆积写,Redis数据不正确,不能直接用,比如直接判断用户领取的卷数量) */ @Override public void receiveCoupon(Long id) { // * 分布式锁防止个人刷单(或lua脚本保证操作原子性,无锁方案) String lockKey = PromotionConstants.COUPON_RECEIVE_REDIS_LOCK_PREFIX + UserContext.getUser(); RLock lock = redissonClient.getLock(lockKey); boolean success = lock.tryLock(); if (!success) { throw new BizIllegalException("领卷业务繁忙"); } try { // * redis查询优惠劵信息 // * 没有查询mysql并放入 if (id == null) { return; } Coupon coupon = queryCouponByCache(id); boolean isFromRedis = coupon != null; // * redis无数据 if (coupon == null) { // * 查数据库 coupon = couponService.getById(id); } // * 校验优惠劵是否存在 if (coupon == null) { throw new DbException("目标优惠劵不存在:" + id); } // * 如果不是从Redis获取,写入Redis重读 if (!isFromRedis) { cacheCouponInfo(coupon); coupon = queryCouponByCache(id); if (coupon == null) { throw new BizIllegalException("优惠劵领取失败"); } } // * 判断发放时间 LocalDateTime now = LocalDateTime.now(); if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) { throw new BizIllegalException("优惠劵不在发放时间内"); } // * 判断库存 if (coupon.getTotalNum() <= 0) { throw new BizIllegalException("优惠劵库存不足"); } // * 统计用户已领取数量 String key = PromotionConstants.USER_COUPON_CACHE_PREFIX + id; Long userId = UserContext.getUser(); // * 可以通过修改此处读取再校验更新为一句increment无锁 Object result = redisTemplate.opsForHash().get(key, userId.toString()); Integer receivedNum = 0; // * redis有数据 if (result != null) { receivedNum = Integer.parseInt(result.toString()); } else { // * 如无数据COUNT返回0 receivedNum = lambdaQuery() .eq(UserCoupon::getUserId, userId) .eq(UserCoupon::getCouponId, id) .count(); } // * 校验单个用户限制领取数 if (receivedNum >= coupon.getUserLimit()) { throw new BizIllegalException("用户领取已达上限"); } // * 更新Redis 用户已领取数量与totalNum redisTemplate.opsForHash().increment(key, userId.toString(), 1L + (result == null ? receivedNum : 0L)); String couponCacheKey = PromotionConstants.COUPON_CACHE_PREFIX + id; // * 前面部分不加锁,可能出现超卖,需要校验结果 Long totalNum = redisTemplate.opsForHash().increment(couponCacheKey, "totalNum", -1L); // * 推送消息至MQ if (totalNum >= 0) { UserCouponDTO dto = new UserCouponDTO(); dto.setCouponId(id); dto.setUserId(userId); rabbitMqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVED, dto); } } finally { lock.unlock(); } }
Java
- waitTime:获取锁的等待时间。当获取锁失败后可以多次重试,直到waitTime时间耗尽。waitTime默认-1,即失败后立刻返回,不重试。
- leaseTime:锁超时释放时间。默认是30,同时会利用WatchDog来不断更新超时时间。需要注意的是,如果手动设置leaseTime值,会导致WatchDog失效。
- TimeUnit:时间单位
通用分布式锁AOP
避免通用的非业务代码对业务的侵入
通过注解标记切入点,同时传递锁参数(名称(解析SPEL表达式得到动态名);等待时间;超时时间;时间单位)
注解
java
package com.tianji.common.autoconfigure.redisson.annotations; import com.tianji.common.autoconfigure.redisson.enums.LockStrategy; import com.tianji.common.autoconfigure.redisson.enums.LockType; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.concurrent.TimeUnit; /** * 分布式锁 **/ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Lock { /** * 加锁key的表达式,支持SPEL表达式 */ String name(); /** * 阻塞超时时长,不指定 waitTime 则按照Redisson默认时长 */ long waitTime() default 1; /** * 锁自动释放时长,默认是-1,其实是30秒 + watchDog模式 */ long leaseTime() default -1; /** * 时间单位,默认为秒 */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** * 如果设定了false,则方法结束不释放锁,而是等待leaseTime后自动释放 */ boolean autoUnlock() default true; /** * 锁的类型,包括:可重入锁、公平锁、读锁、写锁 */ LockType lockType() default LockType.DEFAULT; /** * 锁策略,包括5种,默认策略是 不断尝试获取锁,直到成功或超时,超时后抛出异常 */ LockStrategy lockStrategy() default LockStrategy.FAIL_AFTER_RETRY_TIMEOUT; }
Java
切面
java
package com.tianji.common.autoconfigure.redisson.aspect; import com.tianji.common.autoconfigure.redisson.annotations.Lock; import com.tianji.common.exceptions.BizIllegalException; import com.tianji.common.utils.StringUtils; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.context.expression.MethodBasedEvaluationContext; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.Ordered; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; import org.springframework.expression.TypedValue; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.util.ObjectUtils; import java.lang.reflect.Method; import java.util.regex.Matcher; import java.util.regex.Pattern; @Aspect public class LockAspect implements Ordered { /** * SPEL的正则规则 */ private static final Pattern pattern = Pattern.compile("(\\#\\{([^\\}]*)\\})"); /** * 方法参数解析器 */ private static final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); private final RedissonClient redissonClient; public LockAspect(RedissonClient redissonClient) { this.redissonClient = redissonClient; } //通过环绕加锁,方法执行前加锁,方法执行后根据注解使用解锁 @Around("@annotation(properties)") public Object handleLock(ProceedingJoinPoint pjp, Lock properties) throws Throwable { if (!properties.autoUnlock() && properties.leaseTime() <= 0) { // 不手动释放锁时,必须指定leaseTime时间 throw new BizIllegalException("leaseTime不能为空"); } // 1.基于SPEL表达式解析锁的 name String name = getLockName(properties.name(), pjp); // 2.得到锁对象 RLock rLock = properties.lockType().getLock(redissonClient, name); // 3.尝试获取锁 boolean success = properties.lockStrategy().tryLock(rLock, properties); if (!success) { // 获取锁失败,结束 return null; } try { // 4.执行被代理方法 return pjp.proceed(); } finally { // 5.释放锁 if (properties.autoUnlock()) { rLock.unlock(); } } } /** * 解析锁名称 * * @param name 原始锁名称 * @param pjp 切入点 * @return 解析后的锁名称 */ private String getLockName(String name, ProceedingJoinPoint pjp) { // 1.判断是否存在spel表达式 if (StringUtils.isBlank(name) || !name.contains("#")) { // 不存在,直接返回 return name; } // 2.构建context,也就是SPEL表达式获取参数的上下文环境,这里上下文就是切入点的参数列表 EvaluationContext context = new MethodBasedEvaluationContext( TypedValue.NULL, resolveMethod(pjp), pjp.getArgs(), parameterNameDiscoverer); // 3.构建SPEL解析器 ExpressionParser parser = new SpelExpressionParser(); // 4.循环处理,因为表达式中可以包含多个表达式 Matcher matcher = pattern.matcher(name); while (matcher.find()) { // 4.1.获取表达式 String tmp = matcher.group(); String group = matcher.group(1); // 处理以T或#开头的表达式,避免重复添加# boolean isStaticOrVariable = group.startsWith("T(") || group.startsWith("#"); String expressionStr = isStaticOrVariable ? group : "#" + group; Expression expression = parser.parseExpression(expressionStr); // 4.3.解析出表达式对应的值 Object value = expression.getValue(context); // 4.4.用值替换锁名称中的SPEL表达式 name = name.replace(tmp, ObjectUtils.nullSafeToString(value)); } return name; } private Method resolveMethod(ProceedingJoinPoint pjp) { // 1.获取方法签名 MethodSignature signature = (MethodSignature) pjp.getSignature(); // 2.获取字节码 Class<?> clazz = pjp.getTarget().getClass(); // 3.方法名称 String name = signature.getName(); // 4.方法参数列表 Class<?>[] parameterTypes = signature.getMethod().getParameterTypes(); return tryGetDeclaredMethod(clazz, name, parameterTypes); } private Method tryGetDeclaredMethod(Class<?> clazz, String name, Class<?>... parameterTypes) { try { // 5.反射获取方法 return clazz.getDeclaredMethod(name, parameterTypes); } catch (NoSuchMethodException e) { Class<?> superClass = clazz.getSuperclass(); if (superClass != null) { // 尝试从父类寻找 return tryGetDeclaredMethod(superClass, name, parameterTypes); } } return null; } @Override public int getOrder() { return 0; } }
Java
注:原代码里缺少了Ordered接口实现,可能会导致优先级跟@Transactional冲突,无法保证锁边界大于事务边界,这里代码补上,其中Orderded接口的getOrder方法用于获取AOP切面执行优先级(越小越优先),事务@Transactional的默认是Integer.MAX_VALUE(参见org.springframework.transaction.annotation.EnableTransactionManagement)

顺带一提,在org.springframework.context.annotation.ConfigurationClassUtils下也能见到类似的东西,不过是用于@Configuration配置类的

枚举
锁类型枚举(策略模式)
java
package com.tianji.common.autoconfigure.redisson.enums; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; public enum LockType { DEFAULT(){ @Override public RLock getLock(RedissonClient redissonClient, String name) { return redissonClient.getLock(name); } }, FAIR_LOCK(){ @Override public RLock getLock(RedissonClient redissonClient, String name) { return redissonClient.getFairLock(name); } }, READ_LOCK(){ @Override public RLock getLock(RedissonClient redissonClient, String name) { return redissonClient.getReadWriteLock(name).readLock(); } }, WRITE_LOCK(){ @Override public RLock getLock(RedissonClient redissonClient, String name) { return redissonClient.getReadWriteLock(name).writeLock(); } }, ; public abstract RLock getLock(RedissonClient redissonClient, String name); }
Java
也可使用简单工厂模式(写法2)
java
package com.tianji.promotion.utils; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Component; import java.util.EnumMap; import java.util.Map; import java.util.function.Function; import static com.tianji.promotion.utils.MyLockType.*; @Component public class MyLockFactory { private final Map<MyLockType, Function<String, RLock>> lockHandlers; public MyLockFactory(RedissonClient redissonClient) { this.lockHandlers = new EnumMap<>(MyLockType.class); this.lockHandlers.put(RE_ENTRANT_LOCK, redissonClient::getLock); this.lockHandlers.put(FAIR_LOCK, redissonClient::getFairLock); this.lockHandlers.put(READ_LOCK, name -> redissonClient.getReadWriteLock(name).readLock()); this.lockHandlers.put(WRITE_LOCK, name -> redissonClient.getReadWriteLock(name).writeLock()); } public RLock getLock(MyLockType lockType, String name){ return lockHandlers.get(lockType).apply(name); } }
Java
EnumMap纯用数组实现,可能比HashMap更快
Implementation note: All basic operations execute in constant time. They are likely (though not guaranteed) to be faster than their HashMap counterparts.
实际上在业务里也有相关使用,优惠劵折扣策略里
java
package com.tianji.promotion.strategy.discount; import com.tianji.promotion.enums.DiscountType; import java.util.EnumMap; public class DiscountStrategy { private final static EnumMap<DiscountType, Discount> strategies; static { strategies = new EnumMap<>(DiscountType.class); strategies.put(DiscountType.NO_THRESHOLD, new NoThresholdDiscount()); strategies.put(DiscountType.PER_PRICE_DISCOUNT, new PerPriceDiscount()); strategies.put(DiscountType.RATE_DISCOUNT, new RateDiscount()); strategies.put(DiscountType.PRICE_DISCOUNT, new PriceDiscount()); } public static Discount getDiscount(DiscountType type) { return strategies.get(type); } }
Java
锁失败策略(策略模式)
java
package com.tianji.common.autoconfigure.redisson.enums; import com.tianji.common.autoconfigure.redisson.annotations.Lock; import org.redisson.api.RLock; public enum LockStrategy { /** * 不重试,直接结束,返回false */ SKIP_FAST() { @Override public boolean tryLock(RLock lock, Lock properties) throws InterruptedException { return lock.tryLock(0, properties.leaseTime(), properties.timeUnit()); } }, /** * 不重试,直接结束,抛出异常 */ FAIL_FAST() { @Override public boolean tryLock(RLock lock, Lock properties) throws InterruptedException { boolean success = lock.tryLock(0, properties.leaseTime(), properties.timeUnit()); if (!success) { throw new RuntimeException("请求太频繁"); } return true; } }, /** * 重试,直到超时后,直接结束 */ SKIP_AFTER_RETRY_TIMEOUT() { @Override public boolean tryLock(RLock lock, Lock properties) throws InterruptedException { return lock.tryLock(properties.waitTime(), properties.leaseTime(), properties.timeUnit()); } }, /** * 重试,直到超时后,抛出异常 */ FAIL_AFTER_RETRY_TIMEOUT() { @Override public boolean tryLock(RLock lock, Lock properties) throws InterruptedException { boolean success = lock.tryLock(properties.waitTime(), properties.leaseTime(), properties.timeUnit()); if (!success) { throw new RuntimeException("请求超时"); } return true; } }, /** * 不停重试,直到成功为止 */ KEEP_RETRY() { @Override public boolean tryLock(RLock lock, Lock properties) throws InterruptedException { lock.lock(properties.leaseTime(), properties.timeUnit()); return true; } }, ; public abstract boolean tryLock(RLock lock, Lock properties) throws InterruptedException; }
Java
waitTime参数决定重试时间,没有不重试
业务AOP版本
防止兑换优惠劵业务超卖效果展示
- 选择目标优惠劵

- 设置jmeter参数


- 运行结束,检查Redis 100条√ 1人1张√ 0剩余√


- 检查数据库 100√

- 检查汇总报告 √

- 检查jmeter报告搜索仅100请求成功 √

- 检查请求失败原因 优惠卷库存不足√ 请求超时(分布式锁获取超时)√



- 与策略一致 √

代码:
领取优惠劵


java
@Override @Lock(name = "#T(com.tianji.common.constants.PromotionConstants).COUPON_RECEIVE_REDIS_LOCK_PREFIX#T(com.tianji.common.utils.UserContext).getUser()") public void receiveCouponImplWithAnnotation(Long id) { // * 分布式锁防止个人刷单(或lua脚本保证操作原子性,无锁方案) // * redis查询优惠劵信息 // * 没有查询mysql并放入 if (id == null) { return; } Coupon coupon = queryCouponByCache(id); boolean isFromRedis = coupon != null; // * redis无数据 if (coupon == null) { // * 查数据库 coupon = couponService.getById(id); } // * 校验优惠劵是否存在 if (coupon == null) { throw new DbException("目标优惠劵不存在:" + id); } // * 如果不是从Redis获取,写入Redis重读 if (!isFromRedis) { cacheCouponInfo(coupon); coupon = queryCouponByCache(id); if (coupon == null) { throw new BizIllegalException("优惠劵领取失败"); } } // * 判断发放时间 LocalDateTime now = LocalDateTime.now(); if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) { throw new BizIllegalException("优惠劵不在发放时间内"); } // * 判断库存 if (coupon.getTotalNum() <= 0) { throw new BizIllegalException("优惠劵库存不足"); } // * 统计用户已领取数量 String key = PromotionConstants.USER_COUPON_CACHE_PREFIX + id; Long userId = UserContext.getUser(); // * 可以通过修改此处读取再校验更新为一句increment无锁 Object result = redisTemplate.opsForHash().get(key, userId.toString()); Integer receivedNum = 0; // * redis有数据 if (result != null) { receivedNum = Integer.parseInt(result.toString()); } else { // * 如无数据COUNT返回0 receivedNum = lambdaQuery() .eq(UserCoupon::getUserId, userId) .eq(UserCoupon::getCouponId, id) .count(); } // * 校验单个用户限制领取数 if (receivedNum >= coupon.getUserLimit()) { throw new BizIllegalException("用户领取已达上限"); } // * 更新Redis 用户已领取数量与totalNum redisTemplate.opsForHash().increment(key, userId.toString(), 1L + (result == null ? receivedNum : 0L)); String couponCacheKey = PromotionConstants.COUPON_CACHE_PREFIX + id; // * 前面部分不加锁,可能出现超卖,需要校验结果 Long totalNum = redisTemplate.opsForHash().increment(couponCacheKey, "totalNum", -1L); // * 推送消息至MQ if (totalNum >= 0) { UserCouponDTO dto = new UserCouponDTO(); dto.setCouponId(id); dto.setUserId(userId); rabbitMqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVED, dto); } }
Java
兑换优惠劵

java
@Override @Lock(name = "#T(com.tianji.common.constants.PromotionConstants).COUPON_EXCHANGE_REDIS_LOCK_PREFIX#T(com.tianji.common.utils.UserContext).getUser()") /** * * 其实只用锁两行 */ public void exchangeCouponWithAnnotation(String code) { // * 解析兑换码 long id = CodeUtil.parseCode(code); // * 查询兑换码 ExchangeCode exchangeCode = exchangeCodeService.getById(id); // * 是否存在 if (exchangeCode == null) { throw new DbException("目标兑换码不存在:" + id); } // * 判断是否兑换状态 // * 判断是否过期 LocalDateTime now = LocalDateTime.now(); if (exchangeCode.getStatus() != ExchangeCodeStatus.UNUSED || now.isAfter(exchangeCode.getExpiredTime())) { throw new BizIllegalException("兑换码已使用或已过期"); } // * 判断是否超出领取数量 // * 更新状态(优惠卷领取+1;用户卷新增记录) Coupon coupon = couponService.getById(exchangeCode.getExchangeTargetId()); Long userId = UserContext.getUser(); // * 其实只有这两行要锁,可以单独抽出函数锁 IUserCouponService userCouponService = (IUserCouponService) AopContext.currentProxy(); userCouponService.checkAndCreateUserCouponWithCode(coupon, userId, exchangeCode.getId()); }
Java
优惠劵核销线程池
可能问题
管理端发放优惠劵前端无弹窗
正常预期效果:

解决方案:
1.虚拟机上自带的tj-admin会出现这样的情况,可以使用API直接发放
2.对前端资料的tj-admin在npm i后重新npm run build(注意使用build,dev环境打包,prod打包js文件不全),对打包的dist文件夹重命名tj-admin上传虚拟机替换目录(/usr/local/src)下tj-admin,重新进入即可。
IDEA 2024.3版本的Quick Documentation灰色失效

更新2024.3.3版本恢复

Day09练习参考实现
修改优惠劵

java
/** * 更新优惠劵 */ @Override public void updateCoupon(CouponFormDTO dto) { // * 无更新目标 Long couponId = dto.getId(); if (couponId == null) { return; } // * 查询是否有待更新的数据 Coupon coupon = lambdaQuery() .eq(Coupon::getId, couponId) .eq(Coupon::getStatus, CouponStatus.DRAFT) .one(); if (coupon == null) { throw new BadRequestException("没有可更新的数据"); } // * 拷贝更新 Coupon couponToUpdate = BeanUtils.copyBean(dto, Coupon.class); updateById(couponToUpdate); // * 如果以前指定了范围,先删除 if (coupon.getSpecific()) { // * 唯一键(优惠劵id)查询并删除 couponScopeService.lambdaUpdate() .eq(CouponScope::getCouponId, couponId) .remove(); } // * 新的数据指定了范围,构造保存范围数据 Set<Long> scopes = new HashSet<>(dto.getScopes()); if (dto.getSpecific() && CollUtils.isNotEmpty(scopes)) { List<CouponScope> couponScopeList = scopes.stream() .map(s -> new CouponScope().setCouponId(couponId).setBizId(s)) .collect(Collectors.toList()); couponScopeService.saveBatch(couponScopeList); } }
Java
删除优惠券

java
/** * 删除优惠劵 */ @Override @Transactional public void deleteCoupon(Long id) { // * 没有id就没有待删除数据 if (id == null) { return; } // * 只有待发放状态可以删除 Coupon coupon = getById(id); if (coupon.getStatus() != CouponStatus.DRAFT) { throw new BadRequestException("优惠劵不处于可删除(待发放)状态"); } // * 删除本身与限定条件 removeById(id); if (coupon.getSpecific()) { couponScopeService.lambdaUpdate() .eq(CouponScope::getCouponId, id) .remove(); } }
Java
根据id查询优惠券


java
/** * 根据id查询优惠劵详情 */ @Override public CouponDetailVO queryCouponDetailById(Long id) { // * 健壮性校验 CouponDetailVO vo = new CouponDetailVO(); // * 无指定id,空vo if (id == null) { return vo; } // * 查询coupon Coupon coupon = getById(id); if (coupon == null) { throw new BadRequestException("没有对应id的优惠劵"); } // * 基础拷贝 vo = BeanUtils.copyBean(coupon, CouponDetailVO.class); // * 如果限定范围,补全限定范围 if (coupon.getSpecific()) { // * 查询限定范围 List<CouponScope> scopes = couponScopeService.lambdaQuery() .eq(CouponScope::getCouponId, id) .list(); List<CouponScopeVO> scopeVOList = new ArrayList<>(); // * 不为空,查询对应各限定范围项名字 if (CollUtils.isNotEmpty(scopes)) { // * 选择任一级id最终都会对应到三级id,所以直接调用三级id接口 // * stream不改变顺序,新增与更新均采用Set更新,不存在重复的可能,可以直接适配List传参 List<Long> bizIds = scopes.stream() .map(CouponScope::getBizId) .collect(Collectors.toList()); List<String> scopeNames = categoryCache.getNameByLv3Ids(bizIds); // * 组装集合 for (int i = 0; i < scopes.size(); i++) { CouponScopeVO scopeVO = new CouponScopeVO(); scopeVO.setId(scopes.get(i).getBizId()); scopeVO.setName(scopeNames.get(i)); scopeVOList.add(scopeVO); } } // * 封装限定范围集合至vo vo.setScopes(scopeVOList); } // * 返回vo return vo; }
Java
定时开始发放优惠券
MOD方案



java
// * MOD错开更新范围方案 @XxlJob("checkAndIssueCoupons") public void checkAndIssueCoupons() { // * 获取分片信息 int shardIndex = XxlJobHelper.getShardIndex(); int shardTotal = XxlJobHelper.getShardTotal(); // * 获取任务参数 分页大小 String param = XxlJobHelper.getJobParam(); int size = NumberUtils.parseInt(param); log.debug("未发放优惠券发放中...,index={},size={}", shardIndex, size); // * 处理任务 couponService.checkAndIssueCoupons(shardIndex, shardTotal, size); }
Java
java
/** * 定时发放未发放状态的优惠劵 */ @Override public void checkAndIssueCoupons(int shardIndex, int shardTotal, int size) { LocalDateTime now = LocalDateTime.now(); CouponStatus oldStatus = CouponStatus.UN_ISSUE; CouponStatus newStatus = CouponStatus.ISSUING; // * MOD筛选id,数据修改不重叠,不会多次修改 getBaseMapper().updateCouponIssueStatusByPage(shardIndex, shardTotal, size, oldStatus, now, newStatus); }
Java
java
// * 不确定#{}表达式能否正确求值,并且没有自动导入 @Update("UPDATE tj_promotion.coupon SET status = #{newStatus} WHERE id MOD #{shardTotal} = #{shardIndex} AND " + "issue_begin_time <= " + "#{now} AND coupon.status = #{oldStatus} LIMIT #{size}") Integer updateCouponIssueStatusByPage(@Param("shardIndex") int shardIndex, @Param("shardTotal") int shardTotal, @Param("size") int size, @Param("oldStatus") CouponStatus oldStatus, @Param("now") LocalDateTime now, @Param("newStatus") CouponStatus newStatus);
Java
2PC方案


java
// * 2PC分页更新方案 @XxlJob("checkAndIssueCoupons2PC") public void checkAndIssueCoupons2PC() { // * 获取分片信息 int shardIndex = XxlJobHelper.getShardIndex(); int shardTotal = XxlJobHelper.getShardTotal(); List<String> waitingKeys = new ArrayList<>(); for (int i = 0; i < shardTotal; i++) { if (i != shardIndex) { waitingKeys.add(COUPON_STATUS_ISSUE_2PC_PREFIX + i); } } String key = COUPON_STATUS_ISSUE_2PC_PREFIX + shardIndex; // * 获取任务参数 分页大小 String param = XxlJobHelper.getJobParam(); int size = NumberUtils.parseInt(param); log.debug("(2PC)未发放优惠券发放中...,index={},size={}", shardIndex, size); // * 处理任务 couponService.checkAndIssueCoupons2PC(shardIndex, shardTotal, size, key, waitingKeys); }
Java
java
@Override @Transactional public void checkAndIssueCoupons2PC(int shardIndex, int shardTotal, int size, String key, List<String> waitingKeys) { LocalDateTime now = LocalDateTime.now(); // * 分页查询,只要其他事务没有在读取前提交,数据就不会冲突 Page<Coupon> resultPage = lambdaQuery() .eq(Coupon::getStatus, CouponStatus.UN_ISSUE) .le(Coupon::getIssueBeginTime, now) .page(new Page<>(shardIndex, size)); // * 更改状态,更新数据库 List<Coupon> records = resultPage.getRecords(); if (CollUtils.isNotEmpty(records)) { for (Coupon record : records) { record.setStatus(CouponStatus.ISSUING); } updateBatchById(records); } // * 完成业务,写入自己对应的redis队列 redisTemplate.opsForList().leftPush(key, "random value"); // * 尝试读取其他的队列,获取成功,提交,不成功超时,同样提交(xxljob中设置任务超时[时间大于此处等待,但小于业务周期],让执行超时的进程自行结束任务) long timeout = CouponHandler.COUPON_STATUS_UPDATE_TIMEOUT; TimeUnit unit = CouponHandler.COUPON_STATUS_UPDATE_TIMEOUT_UNIT; // * 有一个超时直接提交 long before = DateUtils.toEpochMilli(LocalDateTime.now()); for (String waitingKey : waitingKeys) { String result = redisTemplate.opsForList().leftPop(waitingKey, timeout, unit); // * 等待超时,业务提交 if (result == null) { return; } long passedSeconds = (DateUtils.toEpochMilli(LocalDateTime.now()) - before) / 1000; timeout -= passedSeconds; // * 总计等待超时,业务提交 if (timeout <= 0) { return; } } // * 成功获得所有其他队列的数据,执行成功业务提交 }
Java
定时结束发放优惠券
MOD方案



java
// * 如何复用上方逻辑? @XxlJob("checkAndFinishCoupons") public void checkAndFinishCoupons() { // * 获取分片信息 int shardIndex = XxlJobHelper.getShardIndex(); int shardTotal = XxlJobHelper.getShardTotal(); // * 获取任务参数 分页大小 String param = XxlJobHelper.getJobParam(); int size = NumberUtils.parseInt(param); log.debug("截止发放的优惠券终止中...,index={},size={}", shardIndex, size); // * 处理任务 couponService.checkAndFinishCoupons(shardIndex, shardTotal, size); }
Java
java
/** * 截止优惠劵发放 */ @Override public void checkAndFinishCoupons(int shardIndex, int shardTotal, int size) { LocalDateTime now = LocalDateTime.now(); CouponStatus oldStatus = CouponStatus.ISSUING; CouponStatus newStatus = CouponStatus.FINISHED; getBaseMapper().updateCouponFinishStatusByPage(shardIndex, shardTotal, size, oldStatus, now, newStatus); }
Java
java
@Update("UPDATE tj_promotion.coupon SET status = #{newStatus} WHERE id MOD #{shardTotal} = #{shardIndex} AND " + "issue_end_time <= " + "#{now} AND coupon.status = #{oldStatus} LIMIT #{size}") Integer updateCouponFinishStatusByPage(@Param("shardIndex") int shardIndex, @Param("shardTotal") int shardTotal, @Param("size") int size, @Param("oldStatus") CouponStatus oldStatus, @Param("now") LocalDateTime now, @Param("newStatus") CouponStatus newStatus);
Java
2PC方案


java
// * 如何复用上方逻辑? @XxlJob("checkAndFinishCoupons2PC") public void checkAndFinishCoupons2PC() { // * 获取分片信息 int shardIndex = XxlJobHelper.getShardIndex(); int shardTotal = XxlJobHelper.getShardTotal(); List<String> waitingKeys = new ArrayList<>(); for (int i = 0; i < shardTotal; i++) { if (i != shardIndex) { waitingKeys.add(COUPON_STATUS_FINISH_2PC_PREFIX + i); } } String key = COUPON_STATUS_FINISH_2PC_PREFIX + shardIndex; // * 获取任务参数 分页大小 String param = XxlJobHelper.getJobParam(); int size = NumberUtils.parseInt(param); log.debug("(2PC)截止发放的优惠券终止中...,index={},size={}", shardIndex, size); // * 处理任务 couponService.checkAndFinishCoupons2PC(shardIndex, shardTotal, size, key, waitingKeys); }
Java
java
@Override @Transactional public void checkAndFinishCoupons2PC(int shardIndex, int shardTotal, int size, String key, List<String> waitingKeys) { LocalDateTime now = LocalDateTime.now(); // * 分页查询,只要其他事务没有在读取前提交,数据就不会冲突 Page<Coupon> resultPage = lambdaQuery() .eq(Coupon::getStatus, CouponStatus.ISSUING) .le(Coupon::getIssueEndTime, now) .page(new Page<>(shardIndex, size)); // * 更改状态,更新数据库 List<Coupon> records = resultPage.getRecords(); if (CollUtils.isNotEmpty(records)) { for (Coupon record : records) { record.setStatus(CouponStatus.FINISHED); } updateBatchById(records); } // * 完成业务,写入自己对应的redis队列 redisTemplate.opsForList().leftPush(key, "random value"); // * 尝试读取其他的队列,获取成功,提交,不成功超时,同样提交(xxljob中设置任务超时[时间大于此处等待,但小于业务周期],让执行超时的进程自行结束任务) long timeout = CouponHandler.COUPON_STATUS_UPDATE_TIMEOUT; TimeUnit unit = CouponHandler.COUPON_STATUS_UPDATE_TIMEOUT_UNIT; // * 有一个超时直接提交 long before = DateUtils.toEpochMilli(LocalDateTime.now()); for (String waitingKey : waitingKeys) { String result = redisTemplate.opsForList().leftPop(waitingKey, timeout, unit); // * 等待超时,业务提交 if (result == null) { return; } long passedSeconds = (DateUtils.toEpochMilli(LocalDateTime.now()) - before) / 1000; timeout -= passedSeconds; // * 总计等待超时,业务提交 if (timeout <= 0) { return; } } // * 成功获得所有其他队列的数据,执行成功业务提交 }
Java
暂停发放

java
/** * 暂停发放优惠劵 */ @Override public void pauseCouponIssue(Long id) { if (id == null) { return; } lambdaUpdate() .eq(Coupon::getId, id) .eq(Coupon::getStatus, CouponStatus.ISSUING) .set(Coupon::getStatus, CouponStatus.PAUSE) .update(); String key = PromotionConstants.COUPON_CACHE_PREFIX + id; redisTemplate.opsForHash().delete(key, "issueBeginTime", "issueEndTime", "totalNum", "userLimit"); }
Java
查询兑换码

java
/** * 分页查询兑换码 */ @Override public PageDTO<CodeVO> queryCodePage(CodeQuery query) { Page<ExchangeCode> page = lambdaQuery() .eq(ExchangeCode::getExchangeTargetId, query.getCouponId()) .eq(ExchangeCode::getStatus, query.getStatus()) .page(query.toMpPageDefaultSortByCreateTimeDesc()); List<ExchangeCode> records = page.getRecords(); if (CollUtils.isEmpty(records)) { return PageDTO.empty(page); } List<CodeVO> codeVOList = BeanUtils.copyList(records, CodeVO.class); return PageDTO.of(page, codeVOList); }
Java
Day10练习参考实现
查询我的优惠券

java
@Override public PageDTO<CouponVO> queryMyCoupon(UserCouponQuery query) { // * 获取筛选状态 Integer status = query.getStatus(); // * 分页查询用户优惠劵 Page<UserCoupon> page = lambdaQuery() .eq(UserCoupon::getUserId, UserContext.getUser()) .eq(status != null, UserCoupon::getStatus, status) .page(query.toMpPageDefaultSortByCreateTimeDesc()); List<UserCoupon> records = page.getRecords(); // * 无用户卷数据 if (CollUtils.isEmpty(records)) { return PageDTO.empty(0L, 0L); } // * 构造优惠劵id集合查询优惠劵信息 List<Long> couponIds = records.stream() .map(UserCoupon::getCouponId) .collect(Collectors.toList()); List<Coupon> couponList = couponService.lambdaQuery() .in(Coupon::getId, couponIds) .list(); // * 无对应优惠劵 if (CollUtil.isEmpty(couponList)) { throw new DbException("部分id优惠劵不存在"); } // * 构造map Map<Long, LocalDateTime> termEndTimeMap = records.stream() .collect(Collectors.toMap(UserCoupon::getCouponId, UserCoupon::getTermEndTime)); // * 补全vo的使用结束时间(与用户卷表有关) List<CouponVO> voList = new ArrayList<>(); for (Coupon coupon : couponList) { CouponVO vo = BeanUtils.copyBean(coupon, CouponVO.class); vo.setTermEndTime(termEndTimeMap.getOrDefault(coupon.getId(), null)); voList.add(vo); } return PageDTO.of(page, voList); }
Java
完善兑换优惠券功能







java
/** * 兑换优惠劵(lua版本) */ @Override public void exchangeCouponWithLua(String code) { // * 不应用redis查询是否使用过,如果内存淘汰了bitmap,下次访问更大序列号时会给较小的位填0,并不能说明就没有使用过 // * 并且一张卷也就只能用一次,正常调用接口对同一张卷查询并不频繁 // * 由于相比领取卷,唯一额外的地方bitmap与zset只用于获取couponId与判使用与否 // * 此处查库已经解决这两者,故而直接复用代码 // * 解析兑换码 long id = CodeUtil.parseCode(code); // * 查询兑换码 ExchangeCode exchangeCode = exchangeCodeService.getById(id); // * 是否存在 if (exchangeCode == null) { throw new DbException("目标兑换码不存在:" + id); } // * 判断是否兑换状态 // * 判断是否过期 LocalDateTime now = LocalDateTime.now(); if (exchangeCode.getStatus() != ExchangeCodeStatus.UNUSED || now.isAfter(exchangeCode.getExpiredTime())) { throw new BizIllegalException("兑换码已使用或已过期"); } Long couponId = exchangeCode.getExchangeTargetId(); // * 获取lua脚本入参 String couponKey = PromotionConstants.COUPON_CACHE_PREFIX + couponId; String userCouponKey = PromotionConstants.USER_COUPON_CACHE_PREFIX + couponId; String exchangeCouponKey = PromotionConstants.EXCHANGE_COUPON_CACHE_PREFIX + code; Long userId = UserContext.getUser(); // * 第一次尝试执行脚本 Long result = redisTemplate.execute(EXCHANGE_COUPON_SCRIPT, List.of(couponKey, userCouponKey, exchangeCouponKey), userId.toString()); // * 健壮性判断 if (result == null) { return; } // * 如果lua返回不成功 if (result != PromotionLuaConstants.SUCCESS) { // * 校验是否因为业务原因失败 validateReceiveCouponBiz(result); // * 非业务原因失败 // * SETNX/HSETNX 乐观锁读库写入Redis if (result == PromotionLuaConstants.ONLY_COUPON_NOT_EXIST) { writeDbCouponInfoToRedis(couponId); } if (result == PromotionLuaConstants.ONLY_USER_COUPON_NOT_EXIST) { writeDbUserCouponCountToRedis(couponId, userId); } if (result == PromotionLuaConstants.BOTH_NOT_EXIST) { writeDbCouponInfoToRedis(couponId); writeDbUserCouponCountToRedis(couponId, userId); } // * 第二次尝试执行脚本(只在Redis操作数据,保障两边都是正确状态的数据) result = redisTemplate.execute(EXCHANGE_COUPON_SCRIPT, List.of(couponKey, userCouponKey, exchangeCouponKey), userId.toString()); // * 健壮性判断 if (result == null) { return; } if (result != PromotionLuaConstants.SUCCESS) { validateReceiveCouponBiz(result); // * 如果仍是缺少数据,抛出异常 throw new BizIllegalException("领取优惠劵失败"); } } // * 成功,发送MQ UserCouponDTO dto = new UserCouponDTO(); dto.setCouponId(couponId); dto.setUserId(userId); dto.setSerialNum(exchangeCode.getId()); rabbitMqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_EXCHANGED, dto); }
Java
java
@RabbitListener( bindings = @QueueBinding( value = @Queue(value = "coupon.exchange.queue", durable = "true"), exchange = @Exchange(value = MqConstants.Exchange.PROMOTION_EXCHANGE, type = ExchangeTypes.TOPIC), key = MqConstants.Key.COUPON_EXCHANGED ) ) public void listenCouponExchangeMessage(UserCouponDTO dto) { // * 无dto结束业务 if (dto == null) { log.error("兑换消息数据为null"); return; } // * 查优惠劵信息用于更新状态(需要总数量作乐观锁,需要查询数据库) Coupon coupon = couponService.getById(dto.getCouponId()); if (coupon == null) { throw new BizIllegalException("目标优惠卷不存在:" + dto.getCouponId()); } // * 调用更新 userCouponService.checkAndCreateUserCouponWithCode(coupon, dto.getUserId(), dto.getSerialNum()); }
Java
java
/** * 校验单人领取数量并更新卷已领取数并添加用户卷记录并更新兑换码状态(工具方法) */ @Transactional @Override public void checkAndCreateUserCouponWithCode(Coupon coupon, Long userId, Long serialNum) { // * 代理对象确保声明式事务有效 IUserCouponService userCouponService = (IUserCouponService) AopContext.currentProxy(); userCouponService.checkAndCreateUserCoupon(coupon, userId); // * 更新兑换码状态 exchangeCodeService.lambdaUpdate() .eq(ExchangeCode::getId, serialNum) .set(ExchangeCode::getUserId, userId) .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED) .update(); }
Java
lua
--[[ 返回值枚举 1 - 仅COUPON不存在 2 - 仅用户条目不存在 6 - 都不存在 -- quick fail 查库写入redis 3 - 判断发放时间 < begin || > end 4 - 判断库存 <= 0 5 - 判断已兑换与已领取量increment 后 > userLimit(coupon) (无失败)减少totalNum 0 - 成功 -- MQ异步更新时延,查库后重查redis ]]-- local ONLY_COUPON_NOT_EXIST = 1 local ONLY_USER_COUPON_NOT_EXIST = 2 local BOTH_NOT_EXIST = 6 local INVALID_TIME = 3 local INVALID_INVENTORY = 4 local EXCEED_USER_LIMIT = 5 local SUCCESS = 0 -- KEYS[1] coupon | keys[2] argv[1] userCoupon -- lua & redis 返回 unix timestamp(s单位) -- coupon不存在 local couponKey = KEYS[1] local userCouponKey = KEYS[2] local exchangeCouponKey = KEYS[3] local userId = ARGV[1] -- ! 返回0如果不存在,注意lua 0为true! local isCouponExist = redis.call('EXISTS', couponKey) > 0 and true or false -- 返回nil如果不存在 nil为false local isUserCouponExist = redis.call('HGET', userCouponKey, userId) and true or false -- 仅COUPON不存在 -- redis.log(redis.LOG_WARNING, '0validation...') if (not isCouponExist and isUserCouponExist) then -- redis.log(redis.LOG_WARNING, 'ONLY_COUPON_NOT_EXIST...') return ONLY_COUPON_NOT_EXIST end -- 仅用户条目不存在 -- redis.log(redis.LOG_WARNING, '1validation...') if (isCouponExist and not isUserCouponExist) then return ONLY_USER_COUPON_NOT_EXIST end -- 都不存在 -- redis.log(redis.LOG_WARNING, 'both...') if (not isCouponExist and not isUserCouponExist) then return BOTH_NOT_EXIST end -- redis.log(redis.LOG_WARNING, '3validation...') -- 校验发放时间 local now = tonumber(redis.call('TIME')[1]) local issueBeginTime = tonumber(redis.call('HGET', couponKey, 'issueBeginTime')) local issueEndTime = tonumber(redis.call('HGET', couponKey, 'issueEndTime')) if (now < issueBeginTime or now > issueEndTime) then return INVALID_TIME -- 过期/未到时间 end -- 校验库存 if (tonumber(redis.call('HGET', couponKey, 'totalNum')) <= 0) then return INVALID_INVENTORY -- 库存不足 end -- 校验已领取数 -- hincrby返回修改后数据 if (tonumber(redis.call('SETNX', exchangeCouponKey, userId)) == 0 or tonumber(redis.call('HGET', couponKey, 'userLimit') ) < redis.call('HINCRBY', userCouponKey, userId, '1')) then return EXCEED_USER_LIMIT -- 用户领取超限 end -- 业务正常,总数-1 redis.call('HINCRBY', couponKey, 'totalNum', '-1') return SUCCESS
Lua
优惠券过期提醒
定时任务检查用户卷信息然后SMS通知即可,没有SMS接口就不提供实现了。
Day11练习参考实现
完善领取优惠劵功能









lua
-- 优惠劵数量存在 -- 对应用户领取量存在 -- redis 时间戳字符串转unix timestamp(s单位) -- Define the timestamp --local function timeToNumber(ts) -- -- 字符串转table -- local year, month, day, hour, min, sec = ts:match("(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)") -- -- local timeTable = { -- year = tonumber(year), -- month = tonumber(month), -- day = tonumber(day), -- hour = tonumber(hour), -- min = tonumber(min), -- sec = tonumber(sec) -- } -- -- * test -- -- for k, v in pairs(timeTable) do -- -- print(k, v) -- -- end -- local mill = os.time(timeTable) -- return tonumber(mill) --end -- * test -- print(timeToNumber("2024-11-28 00:30:00")) -- os.exit() --[[ 返回值枚举 1 - 仅COUPON不存在 2 - 仅用户条目不存在 6 - 都不存在 -- quick fail 查库写入redis 3 - 判断发放时间 < begin || > end 4 - 判断库存 <= 0 5 - 判断已领取量increment 后 > userLimit(coupon) (无失败)减少totalNum 0 - 成功 -- MQ异步更新时延,查库后重查redis ]]-- local ONLY_COUPON_NOT_EXIST = 1 local ONLY_USER_COUPON_NOT_EXIST = 2 local BOTH_NOT_EXIST = 6 local INVALID_TIME = 3 local INVALID_INVENTORY = 4 local EXCEED_USER_LIMIT = 5 local SUCCESS = 0 -- KEYS[1] coupon | keys[2] argv[1] userCoupon -- lua & redis 返回 unix timestamp(s单位) -- coupon不存在 local couponKey = KEYS[1] local userCouponKey = KEYS[2] local userId = ARGV[1] -- ! 返回0如果不存在,注意lua 0为true! local isCouponExist = redis.call('EXISTS', couponKey) > 0 and true or false -- 返回nil如果不存在 nil为false local isUserCouponExist = redis.call('HGET', userCouponKey, userId) and true or false -- 仅COUPON不存在 -- redis.log(redis.LOG_WARNING, '0validation...') if (not isCouponExist and isUserCouponExist) then -- redis.log(redis.LOG_WARNING, 'ONLY_COUPON_NOT_EXIST...') return ONLY_COUPON_NOT_EXIST end -- 仅用户条目不存在 -- redis.log(redis.LOG_WARNING, '1validation...') if (isCouponExist and not isUserCouponExist) then return ONLY_USER_COUPON_NOT_EXIST end -- 都不存在 -- redis.log(redis.LOG_WARNING, 'both...') if (not isCouponExist and not isUserCouponExist) then return BOTH_NOT_EXIST end -- redis.log(redis.LOG_WARNING, '3validation...') -- 校验发放时间 local now = tonumber(redis.call('TIME')[1]) local issueBeginTime = tonumber(redis.call('HGET', couponKey, 'issueBeginTime')) local issueEndTime = tonumber(redis.call('HGET', couponKey, 'issueEndTime')) if (now < issueBeginTime or now > issueEndTime) then return INVALID_TIME -- 过期/未到时间 end -- 校验库存 if (tonumber(redis.call('HGET', couponKey, 'totalNum')) <= 0) then return INVALID_INVENTORY -- 库存不足 end -- 校验已领取数 -- hincrby返回修改后数据 if (tonumber(redis.call('HGET', couponKey, 'userLimit')) < redis.call('HINCRBY', userCouponKey, userId, '1')) then return EXCEED_USER_LIMIT -- 用户领取超限 end -- 业务正常,总数-1 redis.call('HINCRBY', couponKey, 'totalNum', '-1') return SUCCESS
Lua
java
/** * 领取优惠劵(分布式锁)乐观锁版本 lua实现 Redis为正确数据,无超卖,无刷卷 */ @Override public void receiveCouponImplWithLua(Long couponId) { // * 获取lua脚本入参 String couponKey = PromotionConstants.COUPON_CACHE_PREFIX + couponId; String userCouponKey = PromotionConstants.USER_COUPON_CACHE_PREFIX + couponId; Long userId = UserContext.getUser(); // * 第一次尝试执行脚本 Long result = redisTemplate.execute(RECEIVE_COUPON_SCRIPT, List.of(couponKey, userCouponKey), userId.toString()); // * 健壮性判断 if (result == null) { return; } // * 如果lua返回不成功 if (result != PromotionLuaConstants.SUCCESS) { // * 校验是否因为业务原因失败 validateReceiveCouponBiz(result); // * 非业务原因失败 // * SETNX/HSETNX 乐观锁读库写入Redis if (result == PromotionLuaConstants.ONLY_COUPON_NOT_EXIST) { writeDbCouponInfoToRedis(couponId); } if (result == PromotionLuaConstants.ONLY_USER_COUPON_NOT_EXIST) { writeDbUserCouponCountToRedis(couponId, userId); } if (result == PromotionLuaConstants.BOTH_NOT_EXIST) { writeDbCouponInfoToRedis(couponId); writeDbUserCouponCountToRedis(couponId, userId); } // * 第二次尝试执行脚本(只在Redis操作数据,保障两边都是正确状态的数据) result = redisTemplate.execute(RECEIVE_COUPON_SCRIPT, List.of(couponKey, userCouponKey), userId.toString()); // * 健壮性判断 if (result == null) { return; } if (result != PromotionLuaConstants.SUCCESS) { validateReceiveCouponBiz(result); // * 如果仍是缺少数据,抛出异常 throw new BizIllegalException("领取优惠劵失败"); } } // * 成功,发送MQ UserCouponDTO dto = new UserCouponDTO(); dto.setCouponId(couponId); dto.setUserId(userId); rabbitMqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVED, dto); }
Java
java
@RabbitListener( bindings = @QueueBinding( value = @Queue(value = "coupon.received.queue", durable = "true"), exchange = @Exchange(value = MqConstants.Exchange.PROMOTION_EXCHANGE, type = ExchangeTypes.TOPIC), key = MqConstants.Key.COUPON_RECEIVED ) ) public void listenCouponReceiveMessage(UserCouponDTO dto) { // * 无dto结束业务 if (dto == null) { log.error("领劵消息数据为null"); return; } // * 查优惠劵信息用于更新状态 Coupon coupon = couponService.getById(dto.getCouponId()); if (coupon == null) { throw new BizIllegalException("目标优惠卷不存在:" + dto.getCouponId()); } // * 调用更新 userCouponService.checkAndCreateUserCoupon(coupon, dto.getUserId()); }
Java
java
/** * 校验单人领取数量并更新卷已领取数并添加用户卷记录(工具方法) */ @Transactional @Override public void checkAndCreateUserCoupon(Coupon coupon, Long userId) { // * 允许领取,优惠劵领取数+1 boolean success = couponService.lambdaUpdate() .eq(Coupon::getId, coupon.getId()) // * 乐观锁解决超卖 .lt(Coupon::getIssueNum, coupon.getTotalNum()) .setSql("issue_num = issue_num + 1") .update(); if (!success) { throw new DbException("优惠卷领取更新失败"); } // * 用户卷里加一条记录 UserCoupon userCoupon = new UserCoupon(); userCoupon.setUserId(userId); userCoupon.setCouponId(coupon.getId()); // * 设置有效日期 LocalDateTime termBeginTime = coupon.getTermBeginTime(); LocalDateTime termEndTime = coupon.getTermEndTime(); // * 使用天数而不是日期范围 if (termBeginTime == null) { termBeginTime = LocalDateTime.now(); termEndTime = termBeginTime.plusDays(coupon.getTermDays()); } userCoupon.setTermBeginTime(termBeginTime); userCoupon.setTermEndTime(termEndTime); save(userCoupon); }
Java
java
/** * (lua版本工具方法)读库写优惠劵信息到Redis */ private void writeDbCouponInfoToRedis(Long couponId) { // * 查库 Coupon coupon = couponService.getById(couponId); if (coupon == null) { throw new DbException("目标优惠劵不存在:" + couponId); } // * 不在发放状态不能抢 if (coupon.getStatus() != CouponStatus.ISSUING) { throw new BizIllegalException("该优惠劵不在发放状态"); } // * lua脚本原子性HSETNX写入Redis,乐观锁 String key = PromotionConstants.COUPON_CACHE_PREFIX + couponId; // * 由于Redis中lua在沙箱环境中执行,没有os等模块,需要手动转换时间字符串 // * 不如业务层转换为直接存为unix timestamp (单位为s,对应redis) String issueBeginTime = String.valueOf(convertDateTimeToEpochSecond(coupon.getIssueBeginTime())); String issueEndTime = String.valueOf(convertDateTimeToEpochSecond(coupon.getIssueEndTime())); Integer leftNum = coupon.getTotalNum() - coupon.getIssueNum(); String totalNum = leftNum.toString(); String userLimit = coupon.getUserLimit().toString(); redisTemplate.execute(WRITE_COUPON_SCRIPT, List.of(key), issueBeginTime, issueEndTime, totalNum, userLimit); }
Java
java
/** * (lua版本工具方法)读库写用户卷数量信息到Redis */ private void writeDbUserCouponCountToRedis(Long couponId, Long userId) { // * 不计状态,统计所有已领取 Integer count = lambdaQuery() .eq(UserCoupon::getCouponId, couponId) .eq(UserCoupon::getUserId, userId) .count(); // * 无数据数据库COUNT返回0 if (count == null) { throw new DbException("用户卷数据库异常"); } String key = PromotionConstants.USER_COUPON_CACHE_PREFIX + couponId; // * HSETNX,乐观锁 redisTemplate.opsForHash().putIfAbsent(key, userId.toString(), count.toString()); }
Java
Day12练习参考实现
这一节大部分内容都在文档里提及了,这里仅补充文档里没有的Seata分布式事务相关部分。
Seata
加入配置tj-trade

一个注解就行@GlobalTransactional


了解更多
- 作者:CamelliaV
- 链接:https://camelliav.netlify.app/article/tjxt-day09-12
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。





.png?table=block&id=2b3ca147-5df8-80c8-94b3-f9c89b454622&t=2b3ca147-5df8-80c8-94b3-f9c89b454622)



![[2026.2.10]CachyOS调校历程](https://www.notion.so/image/attachment%3A76369e3c-58f3-4acb-951f-4bd2a6546a51%3A114518297_p0.png?table=block&id=2c1ca147-5df8-8010-b740-d61e13162107&t=2c1ca147-5df8-8010-b740-d61e13162107)
![[2026.1.13]Karing施法指北](https://www.notion.so/image/attachment%3A416549db-cd87-426f-8fcb-de34e5640681%3Acard_after_training_(72).png?table=block&id=284ca147-5df8-802a-8753-ed1447e3c02e&t=284ca147-5df8-802a-8753-ed1447e3c02e)

.png?table=block&id=2b0ca147-5df8-80b5-aedb-d7a0d8d0aa7b&t=2b0ca147-5df8-80b5-aedb-d7a0d8d0aa7b)