zoukankan      html  css  js  c++  java
  • Lua + Redis 解决高并发

    一、业务背景

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

    •   约束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[1]; --Lua下表从1开始
    local user_got_key = KEYS[2];
    local total_got_key = KEYS[3];
    --redis args
    local user_per_day_max = tonumber(ARGV[1]);
    local user_max = tonumber(ARGV[2]);
    local max = tonumber(ARGV[3]);
    local userId = ARGV[4];
    local couponId = ARGV[5];
    
    -- 用户每天可领券的最大数量
    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 1; --fail
    end
    
    -- 用户可领券的最大数量
    local user_got = redis.call("hget",user_got_key,couponId);
    if(user_got and tonumber(user_got) >= user_max) then
        return 2; --fail
    end
    
    -- 券的最大数量
    local total_got = redis.call("hget",total_got_key,couponId);
    if(total_got and tonumber(total_got) >= max) then
        return 3; --fail
    end
    
    redis.call("hincrby",user_today_got_key, userId,1); 
    redis.call("hincrby",user_got_key, couponId,1);
    redis.call("hincrby",total_got_key, couponId,1);
    return 0; -- success

    六、不足之处:

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

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

    七、总结

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

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

         

  • 相关阅读:
    很漂亮的按钮css样式(没有用到图片,可直接拷贝代码使用)
    if、while中变量的作用域问题
    笔记
    搭建高可用mongodb集群(一)——配置mongodb
    Java编程:删除 List 元素的三种正确方法
    MySQL 数据类型
    MySQL 通用查询日志(General Query Log)
    mysql 创建一个用户,指定一个数据库
    MySQL 5.7 免安装版配置
    String,StringBuffer与StringBuilder的区别??
  • 原文地址:https://www.cnblogs.com/selfchange/p/redis.html
Copyright © 2011-2022 走看看