一、业务背景

  优惠券业务主要提供用户领券和消券的功能;领取优惠券的动作由用户直接发起,由于资源有限,我们必须对用户的领取动作进行一些常规约束。

  •   约束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

  恭喜!成功领到一个新的优惠券!

二、数据分析

  max,user_max,user_per_day_max 三个值是元数据,基本是静态值(允许修改);

  total_got,user_got,user_today_got  三个值是动态值,且属于三个不同维度,不适合作为一条记录存在表里,需要分三个表记录;

  用户领券时,取出这6个值,一个if 把对应值 比较一下,再依次修改一下领取数量的值;

  三次读取,三次比较,三次更新,完工。

三、问题:并发

  抢券开始,用户积极性不错,不一会儿 券就被抢完了,手慢的用户被告知领券失败,没有问题,收工。

回头看一眼数据,似乎不太妙,超领了。

  并发,万恶的根源。

  用户张三李四 取出的 total_got 值都一样,张三可以领,李四也可以领,于是,if 条件在这一刻失效,

或者张三 连续来两次取出的 user_got 值都一样,于是张三可以领两次,于是,if 条件在这一刻失效。

  先读再写并行,并发问题的根源。

四、解决思路

  从读到写这段时间的数据不一致问题,根源在于用户并行(个人认为并发是时间概念,并行是空间概念),

要解决这个问题,需要让用户串行,单个用户原子性。锁 说它可以做到。

  锁只有一个目的,就是把并行变为串行,但是上锁的方式 五花八门。

  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脚本的逻辑基本为:

五、业务实现(基于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
local user_today_got_key = KEYS[]; --Lua下表从1开始
local user_got_key = KEYS[];
local total_got_key = KEYS[];
--redis args
local user_per_day_max = tonumber(ARGV[]);
local user_max = tonumber(ARGV[]);
local max = tonumber(ARGV[]);
local userId = ARGV[];
local couponId = ARGV[]; -- 用户每天可领券的最大数量
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
return ; --fail
end -- 用户可领券的最大数量
local user_got = redis.call("hget",user_got_key,couponId);
if(user_got and tonumber(user_got) >= user_max) then
return ; --fail
end -- 券的最大数量
local total_got = redis.call("hget",total_got_key,couponId);
if(total_got and tonumber(total_got) >= max) then
return ; --fail
end redis.call("hincrby",user_today_got_key, userId,);
redis.call("hincrby",user_got_key, couponId,);
redis.call("hincrby",total_got_key, couponId,);
return ; -- success

六、不足之处:

  1. 该方案基于单个写节点的 Redis集群,无法适用于多个写节点的Redis集群;

  2. Redis 执行 Lua 脚本 具有了原子性, 但是 Lua脚本内的 多个写操作 没有实现 原子性(事务)。

七、总结

  通过使用Redis + Lua 方案,解决了领券过程中的高并发问题。

  优惠券领券数量约束,可以抽象为 业务+数量约束,可归结为一类问题,类似的业务需求也可以参考该方案。

 

Lua + Redis 解决高并发的更多相关文章

  1. nginx+lua+redis构建高并发应用(转)

    nginx+lua+redis构建高并发应用 ngx_lua将lua嵌入到nginx,让nginx执行lua脚本,高并发,非阻塞的处理各种请求. url请求nginx服务器,然后lua查询redis, ...

  2. Nginx与Redis解决高并发问题

    原文链接:http://bbs.phpchina.com/forum.php?mod=viewthread&tid=229629 第一版产品采用的是Jquery,Nginx,PHP(CI框架) ...

  3. Nginx+Lua+Redis构建高并发应用

    一.  源文来自:http://www.ttlsa.com/nginx/nginx-lua-redis/ 二.  预览如下:

  4. 高并发场景系列(一) 利用redis实现分布式事务锁,解决高并发环境下减库存

    原文:http://blog.csdn.net/heyewu4107/article/details/71009712 高并发场景系列(一) 利用redis实现分布式事务锁,解决高并发环境下减库存 问 ...

  5. 利用redis实现分布式事务锁,解决高并发环境下库存扣减

    利用redis实现分布式事务锁,解决高并发环境下库存扣减   问题描述: 某电商平台,首发一款新品手机,每人限购2台,预计会有10W的并发,在该情况下,如果扣减库存,保证不会超卖 解决方案一 利用数据 ...

  6. asp.net解决高并发的方案.

    asp.net解决高并发的方案. Posted on 2012-11-27 22:31 75077027 阅读(3964) 评论(1) 编辑 收藏 最近几天一直在读代震军的博客,他是 Discuz!N ...

  7. Redis实现高并发分布式序列号

    使用Redis实现高并发分布式序列号生成服务 序列号的构成 为建立良好的数据治理方案,作数据掌握.分析.统计.商业智能等用途,业务数据的编码制定通常都会遵循一定的规则,一般来讲,都会有自己的编码规则和 ...

  8. 转发:php解决高并发

    php解决高并发(转发:https://www.cnblogs.com/walblog/articles/8476579.html) 我们通常衡量一个Web系统的吞吐率的指标是QPS(Query Pe ...

  9. php面试题二--解决网站大流量高并发方案(从url到硬盘来解决高并发方案总结)

    php面试题二--解决网站大流量高并发方案(从url到硬盘来解决高并发方案总结) 一.总结 从外到内解决网站大流量高并发问题---从提交一个url开始(从用户按下搜索栏回车键开始) url最开始会到d ...

随机推荐

  1. Git 安装和使用教程

    Git 安装和使用教程 git 提交 全部文件 git add .  git add xx命令可以将xx文件添加到暂存区,如果有很多改动可以通过 git add -A .来一次添加所有改变的文件.注意 ...

  2. MySQL索引介绍+索引的存储类型+索引的优点和缺点+索引的分类+删除索引

    什么是索引? 索引用于快速找出某个列中有一特定值的行,不使用索引,mysql必须从第1条记录开始读完整的表,直到找出相关的行.表越大,查询数据所花费的实际越多.如果表中查询的列有一个索引,mysql能 ...

  3. VSCode扩展包离线安装

    下载离线包 下载地址:https://marketplace.visualstudio.com/vscode 安装离线包

  4. 深度优先搜索DFS(一)

      实例一  0/1背包问题:   有n件物品,每件物品的重量为w[i],价值为c[i].现在需要选出若干件物品放入一个容量为V的背包中,使得在选入背包的物品重量和不超过容量V的前提下,让背包中的物品 ...

  5. 洛谷题解 CF777A 【Shell Game】

    同步题解 题目翻译(可能有童鞋没读懂题面上的翻译) 给你三张牌0,1,2. 最初选一张,然后依次进行n次交换,交换规则为:中间一张和左边的一张,中间一张和右边一张,中间一张和左边一张...... 最后 ...

  6. 最近想学Json,请问大家有没有什么好的Json教程介绍一下?

    最近想学json,请问大家有没有什么好的Json教程介绍一下? 最近学完java的框架了,想了解一下json,可是找不到相关视频,请大家有这方面的Json教程好资料就介绍下啦,最后有网址链接啦. {} ...

  7. codeblock字体问题

    有的时候在codeblock中打下划线,会显示空格, 这个时候可以修改一下字体 settings->editor->editor settings最上面的fonts框中选择choose,然 ...

  8. Mac端解决(含修改8.0.13版的密码):Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)

    1. 安装mysql但是从来没启动过,今天一启动就报错: Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2 ...

  9. (转)GraphicsMagick、命令行使用示例

    GraphicsMagick是从 ImageMagick 5.5.2 分支出来的,但是现在他变得更稳定和优秀,GM更小更容易安装.GM更有效率.GM的手册非常丰富GraphicsMagick的命令与I ...

  10. CentOS7下安装Redis5.0.2

    1.下载redis 地址 http://download.redis.io/releases/redis-5.0.2.tar.gz 2.解压tar -zxf redis-5.0.2.tar.gz 3. ...