当前位置 博文首页 > 韩超的博客 (hanchao5272):Redis: lua脚本支持以及抢红包案例的

    韩超的博客 (hanchao5272):Redis: lua脚本支持以及抢红包案例的

    作者:[db:作者] 时间:2021-09-05 16:09

    1.关于lua

    Lua脚本可以调用大部分的Redis命令,Redis运行开发者通过编写脚本传入Redis,一次性执行多条命令。

    使用Lua脚本的好处可以参考pipeline:Redis: pipeline基本原理以及Jedis和Redisson的实现示例

    • 提升性能:减少多个命令在I/O上的耗时。
    • 原子操作:一个lua脚本内的多个命令的执行时原子操作。
    • 脚本复用:lua脚本会加载到redis内存中,可以被多次使用。

    2.抢红包实现

    2.1.原理简析

    通过Redis中的两个数据结构实现抢红包逻辑:

    1. 某个红包的子红包List:存放着每个红包的金额。数据结构:List, Key=rp-{红包ID}, value=红包金额
    2. 已抢到红包的用户Hash:存放已经抢到红包的用户及金额。数据结构:Hash, Key=rp-gain-{红包ID}, hashKey={用户ID}, hashValue={红包金额}

    抢红包的基本逻辑:

    1. 查看用户Hash中是否存在用户,如果存在,则返回0,代表之前抢到过;否则继续。
    2. 查询红包List中的剩余红包个数,如果为0,则返回2,代表红包已抢完;否则继续。
    3. 从红包List中pop一个红包,和用户信息一起放在用户Hash中,并且返回1,代表本次已抢到

    2.2.lua脚本

    -- 抢红包的lua脚本
    -- KEYS[1] = 红包队列Key
    -- KEYS[2] = 抢到红包的用户Hash Key
    -- ARGV[1] = 用户ID
    -- 如果用户已经抢到过红包,则返回0
     if redis.call('hexists',KEYS[2],ARGV[1]) ~= 0 then 
       return 0 
     end 
    -- 如果用户没抢到过,先查看红包数量是否足够
     if redis.call('llen',KEYS[1]) ~= 0 then 
    -- 	如果仍剩余红包,则取出一个红包
       local money = redis.call('rpop',KEYS[1]) 
    -- 	将用户信息和红包金额保存
       redis.call('hset',KEYS[2],ARGV[1],money) 
       return 1 
     end 
    -- 如果无红包了,则返回2表示红包已经抢完
       return 2 
    

    2.3.Java代码

    Redis工具类:RedisUtil

    提供Redis连接池的初始化、连接获取与连接释放。

    /**
     * <p>Redis工具类</P>
     *
     * @author hanchao
     */
    public class RedisUtil {
        /**
         * Redis连接池
         */
        private static JedisPool jedisPool;
    
        static {
            JedisPoolConfig config = new JedisPoolConfig();
            config.setMaxTotal(1024);
            config.setMaxIdle(200);
            config.setMaxWaitMillis(10000);
            config.setTestOnBorrow(true);
            jedisPool = new JedisPool(config, "127.0.0.1", 6379, 10000, null, 0);
        }
    
        /**
         * 获取Jedis实例
         */
        public synchronized static Jedis getConnection() {
            try {
                if (jedisPool != null) {
                    return jedisPool.getResource();
                } else {
                    return null;
                }
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        }
    
        /**
         * 释放jedis资源
         */
        public static void close(final Jedis jedis) {
            if (jedis != null) {
                jedis.close();
            }
        }
    }
    

    客户端:RedisLuaDemo

    主要流程:

    1. 清空红包List和用户Hash的缓存数据。这一步只是为了方便测试。
    2. 伪造红包List数据:随机生成金额,并记录总金额。
    3. 加载抢红包的Lua脚本,加载之后,redis返回这个脚本的sha加密值。
    4. 创建多个线程进行抢红包。
    5. 查询Redis,展示抢红包结果,对比实际红包被抢总金额。
    /**
     * <p>抢红包</P>
     * 1.某个红包的子红包队列:存放着每个红包的金额。数据结构:List, Key=rp-{红包ID}, value=红包金额
     * 2.已抢到红包的用户散列:存放已经抢到红包的用户及金额。数据结构:Hash, Key=rp-gain-{红包ID}, hashKey={用户ID}, hashValue={红包金额}
     *
     * @author hanchao
     */
    @Slf4j
    public class RedisLuaDemo {
        /**
         * 抢红包的lua脚本
         * KEYS[1] = 红包队列Key
         * KEYS[2] = 抢到红包的用户Hash Key
         * ARGV[1] = 用户ID
         */
        private static final String TEST_SCRIPT = "" +
                //如果用户已经抢到过红包,则返回0
                " if redis.call('hexists',KEYS[2],ARGV[1]) ~= 0 then " +
                "   return 0 " +
                " end " +
                //如果用户没抢到过,先查看红包数量是否足够
                " if redis.call('llen',KEYS[1]) ~= 0 then " +
                //如果仍剩余红包,则取出一个红包
                "   local money = redis.call('rpop',KEYS[1]) " +
                //将用户信息和红包金额保存
                "   redis.call('hset',KEYS[2],ARGV[1],money) " +
                "   return 1 " +
                " end " +
                //如果无红包了,则返回2表示红包已经抢完
                "   return 2 ";
    
        public static void main(String[] args) throws InterruptedException {
            //红包List Key
            String redPacketKey = "rp-list-9527";
            //抢到红包的用户hash Key
            String gainUserKey = "rp-user-map-9527";
            //红包分发总金额
            Integer sum = 0;
            //红包实际被抢总金额
            AtomicReference<Integer> total = new AtomicReference<>(0);
    
            //门闩一:以便所有线程同时开始运行
            CountDownLatch switchLatch = new CountDownLatch(1);
            //门闩二:以便所有线程都能运行完成
            CountDownLatch countLatch = new CountDownLatch(10);
            //线程池
            ExecutorService executorService = Executors.newCachedThreadPool();
    
            Jedis jedis = null;
            try {
                jedis = RedisUtil.getConnection();
                if (Objects.nonNull(jedis)) {
                    //先清空
                    jedis.del(redPacketKey);
                    jedis.del(gainUserKey);
    
                    //预先生成5个红包
                    for (int i = 0; i < 5; i++) {
                        int money = RandomUtils.nextInt(100, 150);
                        sum += money;
                        //存入缓存
                        jedis.lpush(redPacketKey, String.valueOf(money));
                    }
                    List<String> redPacketList = jedis.lrange(redPacketKey, 0, jedis.llen(redPacketKey));
                    log.info("共生成{}元的红包,金额分别为:{}", sum, redPacketList);
    
                    //脚本加载之后生成的sha编码
                    String scriptSha = jedis.scriptLoad(TEST_SCRIPT);
    
                    //脚本执行所需的Key列表,也可以在脚本中直接写死
                    List<String> keyList = Lists.newArrayList(redPacketKey, gainUserKey);
    
                    log.info("=============开始抢红包=============");
                    //有7个人抢红包,有些人抢了2次
                    for (int i = 0; i < 10; i++) {
                        //有些人手快,抢了多次
                        int finalUserId = i % 7;
                        //进行抢红包
                        executorService.submit(new GrabRedPacketTask(switchLatch, countLatch, finalUserId, scriptSha, keyList));
                    }
                    switchLatch.countDown();
                    executorService.shutdown();
                    countLatch.await();
    
                    //显示抢红包情况
                    log.info("=============抢红包结束=============");
                    Map<String, String> gainUserMap = jedis.hgetAll(gainUserKey);
                    gainUserMap.forEach((userId, money) -> {
                        log.info("UserId:{},money:{}", userId, money);
                        Integer now = total.get();
                        total.compareAndSet(now, now + Integer.parseInt(money));
                    });
                    log.info("共生成{}元的红包,实际被抢红包总额{}元。", sum, total.get());
                }
            } finally {
                //关闭redis
                RedisUtil.close(jedis);
            }
        }
    }
    

    抢红包线程:GrabRedPacketTask

    通过Redis执行Lua脚本一般需要三个参数:

    1. 脚本的sha值,用于定位脚本。
    2. keyList,Key参数列表,在脚本中通过KEYS[1]、KEYS[2] … KEYS[n-1]来获取参数。
    3. argsList,其他参数列表,在脚本中通过ARGV[1]、ARGV[2] … ARGV[n-1]来获取参数。
    /**
     * <p>抢红包线程</P>
     *
     * @author hanchao
     */
    @Slf4j
    @AllArgsConstructor
    public class GrabRedPacketTask implements Runnable {
        /**
         * 门闩一:以便所有线程同时开始运行
         */
        private CountDownLatch switchLatch;
        /**
         * 门闩二:以便所有线程都能运行完成
         */
        private CountDownLatch countLatch;
        /**
         * 用户ID
         */
        private Integer userId;
        /**
         * 抢红包脚本sha
         */
        private String scriptSha;
        /**
         * 抢红包脚本执行所需的Key列表
         */
        private List<String> keyList;
    
        /**
         * 抢红包
         */
        @Override
        public void run() {
            try {
                switchLatch.await();
            } catch (InterruptedException e) {
                log.error("error");
            }
            //脚本执行所需的其他参数列表,也可以在脚本中直接写死
            List<String> argList = Lists.newArrayList(String.valueOf(userId));
    
            Jedis jedis = null;
            try {
                //抢红包
                jedis = RedisUtil.getConnection();
                if (Objects.nonNull(jedis)) {
                    //这里的脚本利用的是其他地方已经加载好的
                    String result = jedis.evalsha(scriptSha, keyList