Redis: lua腳本支持以及搶紅包案例的簡單實現

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, argList).toString();
                switch (Integer.parseInt(result)) {
                    case 0:
                        log.info("UserId:{} 已經搶到了紅包,不能再搶.", userId);
                        break;
                    case 1:
                        log.info("UserId:{} 眼疾手快,搶到了紅包 !!!", userId);
                        break;
                    case 2:
                        log.info("UserId:{} 手慢,沒搶到紅包.", userId);
                        break;
                    default:
                        log.info("Illegal Result: {}", result);
                        break;
                }
            }
        } finally {
            countLatch.countDown();
            RedisUtil.close(jedis);
        }
    }
}

運行結果:

 INFO - 共生成607元的紅包,金額分別爲:[115, 137, 101, 145, 109] 
 INFO - =============開始搶紅包============= 
 INFO - UserId:0 眼疾手快,搶到了紅包 !!! 
 INFO - UserId:2 眼疾手快,搶到了紅包 !!! 
 INFO - UserId:1 眼疾手快,搶到了紅包 !!! 
 INFO - UserId:0 已經搶到了紅包,不能再搶. 
 INFO - UserId:6 眼疾手快,搶到了紅包 !!! 
 INFO - UserId:5 眼疾手快,搶到了紅包 !!! 
 INFO - UserId:2 已經搶到了紅包,不能再搶. 
 INFO - UserId:4 手慢,沒搶到紅包. 
 INFO - UserId:3 手慢,沒搶到紅包. 
 INFO - UserId:1 已經搶到了紅包,不能再搶. 
 INFO - =============搶紅包結束============= 
 INFO - UserId:0,money:109 
 INFO - UserId:1,money:101 
 INFO - UserId:2,money:145 
 INFO - UserId:5,money:115 
 INFO - UserId:6,money:137 
 INFO - 共生成607元的紅包,實際被搶紅包總額607元。 

3.注意事項

  • 類似於pipeline,不要在lua腳本中執行耗時長的命令或者過多的命令,以防阻塞其他命令。
  • lua腳本會加載到redis內存中,節點重啓,則腳本消失。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章