type
status
date
slug
summary
tags
category
titleIcon
password
icon
calloutIcon
🍃
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 示例

debug lua脚本

  • redis中lua也是不能使用print打印消息的,这时可以连上本地测试redis,在lua中使用redis.log()打印到redis的日志文件中调试。
  • 调试脚本,-a传入密码evalsha读取$中script load读取的lua脚本返回的哈希值,进而选定eval要执行的脚本,之后传参,key的数量,key值列表KEYS,参数列表ARGV。
  • 需要注意的是lua里只有nil和false才算做false,其他均为true,所以下方脚本不能直接把EXISTS返回值当bool使用。
参阅:
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)
notion image
notion image
notion image
notion image

暴露代理对象使内部调用下事务生效

情况说明与解决方案

  • 对于非事务方法调用事务方法,由于此时内部调用为普通对象(this)的调用,不是代理对象的调用,会出现声明式事务失效的情况,需要获得代理对象,通过代理对象调用确保事务生效。
  • 启动类添加注解,暴露代理对象。
notion image
  • 未实现异步写时候的领劵代码,获取代理对象进行调用。

事务边界与锁边界问题

  • 仍参考上方代码,如果将内部调用的函数@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任务

兑换码算法

基本要求

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

Base32

notion image
  • 明文50bit数据
  • 密文10位base32编码结果

明文结构

notion image
  • 计算过程
notion image
notion image
notion image

领取优惠劵的lua方案

乐观锁解决领劵中超卖与刷卷问题

流程图

  • 核心思想:两次尝试执行lua脚本,中间返回值为不存在则查库获得数据,之后通过hsetnx保证仅写入redis一次,之后重试。
  • 代码参见Day10练习
notion image

乐观锁与悲观锁的简易性能测试

预先说明

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

参考指标

  • 手动连测每次取第3次结果,2000并发,单个测3次。
悲观锁
notion image
乐观锁
notion image
 

兑换优惠劵的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号成功兑换):
notion image
notion image
缓存效果:
notion image
notion image
notion image

插件分享

show comment,可以在每行代码处显示出对应注释的插件(不修改源文件)
notion image
参考效果(自动显示,并不修改文件内容)
notion image

@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.存入标识:存入自身线程标识,删除时如果仍是自身标识才可删除,防止锁误删(避免不了)(图源:
    • notion image
      notion image
      但判断与删除不是原子的,仍可能误删
      notion image
      超时释放难以避免锁误删,锁的操作需要原子性,主从同步存在延迟,同一线程无法多次获取同一锁可能死锁,所以需要解决:
    • 1.超时问题:WatchDog机制,锁成功开定时任务,锁到期前自动续期避免超时释放,同时宕机后一同停止,避免死锁
    • 2.原子性问题:Lua脚本
    • 3.锁重入:类似Synchronized,可使用Hash记录持有者与重入次数,次数0时删除
    • 4.主从同步延迟:RedLock
    • 5.锁失败重试
成熟解决方案:Redisson
 

Redisson Quick Start

maven引入

配置类与自动装配

ConditionalOnClass自动装配,引入Redisson依赖时配置才生效
入参的RedisProperties来源:
notion image
notion image
resources/META-INF/spring.factories:

基本使用

场景实例
  • waitTime:获取锁的等待时间。当获取锁失败后可以多次重试,直到waitTime时间耗尽。waitTime默认-1,即失败后立刻返回,不重试。
  • leaseTime:锁超时释放时间。默认是30,同时会利用WatchDog来不断更新超时时间。需要注意的是,如果手动设置leaseTime值,会导致WatchDog失效。
  • TimeUnit:时间单位

通用分布式锁AOP

避免通用的非业务代码对业务的侵入
通过注解标记切入点,同时传递锁参数(名称(解析SPEL表达式得到动态名);等待时间;超时时间;时间单位)

注解

切面

注:原代码里缺少了Ordered接口实现,可能会导致优先级跟@Transactional冲突,无法保证锁边界大于事务边界,这里代码补上,其中Orderded接口的getOrder方法用于获取AOP切面执行优先级(越小越优先),事务@Transactional的默认是Integer.MAX_VALUE(参见org.springframework.transaction.annotation.EnableTransactionManagement)
notion image
顺带一提,在org.springframework.context.annotation.ConfigurationClassUtils下也能见到类似的东西,不过是用于@Configuration配置类的
notion image

枚举

锁类型枚举(策略模式)
也可使用简单工厂模式(写法2)
EnumMap纯用数组实现,可能比HashMap更快
Implementation note: All basic operations execute in constant time. They are likely (though not guaranteed) to be faster than their HashMap counterparts.
实际上在业务里也有相关使用,优惠劵折扣策略里
锁失败策略(策略模式)
waitTime参数决定重试时间,没有不重试

业务AOP版本

防止兑换优惠劵业务超卖效果展示
  • 选择目标优惠劵
notion image
  • 设置jmeter参数
notion image
notion image
  • 运行结束,检查Redis 100条√ 1人1张√ 0剩余√
notion image
notion image
  • 检查数据库 100√
notion image
  • 检查汇总报告 √
notion image
  • 检查jmeter报告搜索仅100请求成功 √
notion image
  • 检查请求失败原因 优惠卷库存不足√ 请求超时(分布式锁获取超时)√
notion image
notion image
notion image
  • 与策略一致 √
notion image
代码:
领取优惠劵
notion image
notion image
兑换优惠劵
notion image
 

优惠劵核销线程池

可能问题

管理端发放优惠劵前端无弹窗

正常预期效果:
notion image
解决方案:
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灰色失效

notion image
更新2024.3.3版本恢复
notion image

Day09练习参考实现

修改优惠劵

notion image

删除优惠券

notion image

根据id查询优惠券

notion image
notion image

定时开始发放优惠券

MOD方案
notion image
notion image
notion image
2PC方案
notion image
notion image

定时结束发放优惠券

MOD方案
notion image
notion image
notion image
2PC方案
notion image
notion image

暂停发放

notion image

查询兑换码

notion image

Day10练习参考实现

查询我的优惠券

notion image

完善兑换优惠券功能

notion image
notion image
notion image
notion image
notion image
notion image
notion image

优惠券过期提醒

定时任务检查用户卷信息然后SMS通知即可,没有SMS接口就不提供实现了。

Day11练习参考实现

完善领取优惠劵功能

notion image
notion image
notion image
notion image
notion image
notion image
notion image
notion image
notion image

Day12练习参考实现

这一节大部分内容都在文档里提及了,这里仅补充文档里没有的Seata分布式事务相关部分。
Seata
加入配置tj-trade
notion image
一个注解就行@GlobalTransactional
notion image
notion image

了解更多

 
天机学堂Day06-Day08复盘-点赞|积分|排行榜业务医学图像处理入门
Loading...
CamelliaV
CamelliaV
Java;CV;ACGN
最新发布
单例模式的四种写法
2025-4-24
体验MCP
2025-4-24
MetingJS使用自定义音乐源-CF+Huggingface部署
2025-4-2
博客访问站点测速分析与对比
2025-3-26
前端模块化
2025-3-16
Voxel2Mesh相关论文精读与代码复现
2025-3-15
公告
计划:
  • LLM相关
  • 支付业务 & 双token无感刷新
  • (线程池计算优惠方案)天机学堂Day09-Day12复盘-优惠劵业务
  • (业务复盘,技术汇总)天机学堂完结复盘
  • hot 100
 
2024-2025CamelliaV.

CamelliaV | Java;CV;ACGN