1、业务背景redis
优惠券业务主要提供用户领券和消券的功能;领取优惠券的动做由用户直接发起,因为资源有限,咱们必须对用户的领取动做进行一些常规约束。sql
- 约束1(优惠券维度): 券的最大数量 max;
- 约束2(用户维度): 每一个用户可领取的最大数量 user_max;
为了知足一些特殊场景,好比连续几天的大促活动,为了吸引用户,容许用户天天领取一次优惠券。因而,数据库
- 约束3(用户加时间维度): 每一个用户天天可领取的最大数量 user_per_day_max;
目前,用户领券只有上述三个约束,将来,也许,会有更复杂的约束需求。缓存
为了同时知足上述三个约束,优惠券业务分别 记录了 每一个用户当天已领取的数量 user_today_got,每一个用户已领取的数量user_got , 全部用户领取的数量 total_got,并发
只要在用户领券前 如下三个条件成立:分布式
- user_today_got < user_per_day_max
- user_got < user_max
- total_got < max
恭喜!成功领到一个新的优惠券!ide
2、数据分析高并发
max,user_max,user_per_day_max 三个值是元数据,基本是静态值(容许修改);性能
total_got,user_got,user_today_got 三个值是动态值,且属于三个不一样维度,不适合做为一条记录存在表里,须要分三个表记录;lua
用户领券时,取出这6个值,一个if 把对应值 比较一下,再依次修改一下领取数量的值;
三次读取,三次比较,三次更新,完工。
3、问题:并发
抢券开始,用户积极性不错,不一下子 券就被抢完了,手慢的用户被告知领券失败,没有问题,收工。
回头看一眼数据,彷佛不太妙,超领了。
并发,万恶的根源。
用户张三李四 取出的 total_got 值都同样,张三能够领,李四也能够领,因而,if 条件在这一刻失效,
或者张三 连续来两次取出的 user_got 值都同样,因而张三能够领两次,因而,if 条件在这一刻失效。
先读再写并行,并发问题的根源。
4、解决思路
从读到写这段时间的数据不一致问题,根源在于用户并行(我的认为并发是时间概念,并行是空间概念),
要解决这个问题,须要让用户串行,单个用户原子性。锁 说它能够作到。
锁只有一个目的,就是把并行变为串行,可是上锁的方式 五花八门。
1. Java应用内存锁
Java中自带不少内存锁,synchronize,各类Lock,可是优惠券服务多机部署,内存锁没法知足需求;
2. Mysql数据库锁
优惠券服务使用MySql(一个写节点),innodb存储引擎,innodb 支持 行锁。
利用innodb的行锁机制,可使用两种方式实现用户领券的原子性:
第一种,读取以前上锁, 更新以后解锁
select ... from table where ... for update;
update table set ....
优势: 简单明了; 缺点: select 和 update 之间处理 出异常或应用异常终止 会产生死锁。
第二中,利用update 锁行机制,加上where 条件 判断数据,也是读取前上锁,更新后解锁。
update table set .... where ....
优势:简单明了; 缺点: 效率不高
另外更新操做直接命中数据库会对数据库产生很大的压力,因此数据库锁没法知足抢券业务;
3. Redis分布式内存锁
优惠券服务使用单节点Redis,Redis 支持setnx命令。
利用setnx命令,能够在应用中自建锁及维护锁的生命周期。
基本思路是领券前将优惠券的key经过 setnx 命令写进 redis,成功则以后便执行后续的三次读取 比较 和更新,
最后 del 命令删除优惠券的key。
优势:逻辑简单,实现简单,total_got,user_got,user_today_got 三个值 存哪里不受任何限制。
缺点:不太可靠,setnx 成功后,应用出现异常,没有执行最后的del , 会产生死锁;也能够在 setnx 后再
设置一个过时时间,是的,这是一个办法,只须要保证过时时间大于 接口的最大执行时间。
另外,也可使用 官方推荐的 分布式Redis锁 开源实现 Redisson。

3. Redis的 pipeline & lua
Redis 使用单线程处理命令队列,串行执行每一个命令,Redis数据读写操做不存在并行。
若是须要修改的数据都存储在Redis中,那么能够将一批排序的命令发给Redis, Redis命令队列保证不会打乱你的排序,而且保证不会有人插队便可。
Redis提供了pipeline的方式一次解析接收多个命令,而且保证不会打乱你的命令顺序,可是很惋惜,Redis不保证 不会有人插队,pipeline的设计目的是
为了节约RTT。
优惠券业务须要一系列操做具备原子性,pipeline方式不可行。
Redis 支持执行 Lua 脚本,提供 eval 命令执行Lua脚本,注意,eval是一个命令,Redis单个命令都是原子执行的,执行Lua脚本固然也是原子性的。
Lua脚本能够承载丰富的业务逻辑和Redis数据操做,领券只须要原子性的三次读取三次比较以及三次更新,Redis + Lua 彻底能够胜任,而且提供不错的性能。
采用Redis + Lua 的解决思路以下:

Lua脚本的逻辑基本为:

5、业务实现(基于Spring)
1. 配置Lua脚本
- @Configuration
- public class RedisLuaConfig {
- @Bean("luaScript")
- public RedisScript<Long> obtainCouponScript() {
- DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
- redisScript.setLocation(new ClassPathResource("lua/script.lua"));
- redisScript.setResultType(Long.class);
- return redisScript;
- }
- }
2. 加载和执行
- @Slf4j
- @Component
- public class RedisScriptService {
-
- @Autowired
- private StringRedisTemplate redisTemplate;
- @Resource(name = "luaScript")
- private RedisScript<Long> luaScript;
-
- /**
- * 启动时加载,手动加载
- */
- @PostConstruct
- public void loadScript() {
- redisTemplate.execute(new RedisCallback<String>() {
- @Override
- public String doInRedis(RedisConnection connection) throws DataAccessException {
- StringRedisConnection redisConnection = (StringRedisConnection) connection;
- return redisConnection.scriptLoad(luaScript.getScriptAsString());
- }
- });
- }
-
- /**
- * 执行脚本
- * @param keys
- * @param args
- * @return
- */
- public int execScript(List<String> keys,List<String> args) {
- try {
- Long scriptValue = redisTemplate.execute(luaScript,keys,args.toArray());
- return scriptValue.intValue();
- } catch (Exception e) {
- log.error("execute script error", e);
- return -1;
- }
- }
- }
屡次加载问题:
Redis拿到Lua脚本时会先计算其sha1值,sha1值已存在的话会忽略加载,因此当Lua脚本文件内容没有变化时只会加载一次。
RedisTemplate 执行 RedisScript 对象(Lua脚本)过程:
- 序列化参数;
- RedisScript计算lua脚本 sha1值 (必定和Redis中计算出的sha1值相同);
- 尝试使用evalSha 命令执行 Lua脚本;
- evalSha失败时,使用eval 命令执行 Lua脚本;
- 序列化返回值,返回
执行过程源码以下:
- protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys,
- byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {
- Object result;
- try {
- //script.getSha1()方法中会计算sha1值
- result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
- } catch (Exception e) {
- if (!ScriptUtils.exceptionContainsNoScriptError(e)) {
- throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);
- }
- //scriptBytes()序列化脚本内容
- result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs); // eval方法执行,redis会缓存脚本内容,可是不会记录其 sha1 值; 下一次evalSha时,redis会表示不认识该sha1值; 因此上面须要手动加载脚本
- }
- if (script.getResultType() == null) {
- return null;
- }
- return deserializeResult(resultSerializer, result);
- }
3. Lua脚本
- --redis keys
- ]; --Lua下表从1开始
- ];
- ];
- --redis args
- ]);
- ]);
- ]);
- ];
- ];
-
- -- 用户天天可领券的最大数量
- local user_today_got = redis.call("hget", user_today_got_key, userId);
- if(user_today_got and tonumber(user_today_got) >= user_per_day_max) then
- ; --fail
- end
-
- -- 用户可领券的最大数量
- local user_got = redis.call("hget",user_got_key,couponId);
- if(user_got and tonumber(user_got) >= user_max) then
- ; --fail
- end
-
- -- 券的最大数量
- local total_got = redis.call("hget",total_got_key,couponId);
- if(total_got and tonumber(total_got) >= max) then
- ; --fail
- end
-
- redis.call();
- redis.call();
- redis.call();
- ; -- success
6、不足之处:
1. 该方案基于单个写节点的 Redis集群,没法适用于多个写节点的Redis集群;
2. Redis 执行 Lua 脚本 具备了原子性, 可是 Lua脚本内的 多个写操做 没有实现 原子性(事务)。
7、总结
经过使用Redis + Lua 方案,解决了领券过程当中的高并发问题。
优惠券领券数量约束,能够抽象为 业务+数量约束,可归结为一类问题,相似的业务需求也能够参考该方案。
https://www.jianshu.com/p/119b8014377d