Redis Lua實戰(zhàn)

Redis Lua 這個技術(shù),我之前就在關(guān)注,今天有空,我把項目中基于Redis實現(xiàn)的ID生成器改成用lua腳本實現(xiàn),防止并發(fā)id沖突問題

Redis中使用Lua的好處

  • 減少網(wǎng)絡(luò)開銷??梢詫⒍鄠€請求通過腳本的形式一次發(fā)送,減少網(wǎng)絡(luò)時延
  • 原子操作。redis會將整個腳本作為一個整體執(zhí)行,中間不會被其他命令插入。因此在編寫腳本的過程中無需擔心會出現(xiàn)競態(tài)條件,無需使用事務(wù)。
  • 復(fù)用??蛻舳税l(fā)送的腳步會永久存在redis中,這樣,其他客戶端可以復(fù)用這一腳本而不需要使用代碼完成相同的邏輯。

Redis Lua腳本與事務(wù)

從定義上來說, Redis 中的腳本本身就是一種事務(wù), 所以任何在事務(wù)里可以完成的事, 在腳本里面也能完成。 并且一般來說, 使用腳本要來得更簡單,并且速度更快。

使用事務(wù)時可能會遇上以下兩種錯誤:

  • 事務(wù)在執(zhí)行 EXEC 之前,入隊的命令可能會出錯。比如說,命令可能會產(chǎn)生語法錯誤(參數(shù)數(shù)量錯誤,參數(shù)名錯誤,等等),或者其他更嚴重的錯誤,比如內(nèi)存不足(如果服務(wù)器使用 maxmemory 設(shè)置了最大內(nèi)存限制的話)。
  • 命令可能在 EXEC 調(diào)用之后失敗。舉個例子,事務(wù)中的命令可能處理了錯誤類型的鍵,比如將列表命令用在了字符串鍵上面,諸如此類。

對于發(fā)生在 EXEC 執(zhí)行之前的錯誤,客戶端以前的做法是檢查命令入隊所得的返回值:如果命令入隊時返回 QUEUED ,那么入隊成功;否則,就是入隊失敗。如果有命令在入隊時失敗,那么大部分客戶端都會停止并取消這個事務(wù)。

不過,從 Redis 2.6.5 開始,服務(wù)器會對命令入隊失敗的情況進行記錄,并在客戶端調(diào)用 EXEC 命令時,拒絕執(zhí)行并自動放棄這個事務(wù)。

在 Redis 2.6.5 以前, Redis 只執(zhí)行事務(wù)中那些入隊成功的命令,而忽略那些入隊失敗的命令。 而新的處理方式則使得在流水線(pipeline)中包含事務(wù)變得簡單,因為發(fā)送事務(wù)和讀取事務(wù)的回復(fù)都只需要和服務(wù)器進行一次通訊。

至于那些在 EXEC 命令執(zhí)行之后所產(chǎn)生的錯誤, 并沒有對它們進行特別處理: 即使事務(wù)中有某個/某些命令在執(zhí)行時產(chǎn)生了錯誤, 事務(wù)中的其他命令仍然會繼續(xù)執(zhí)行。

經(jīng)過測試lua中發(fā)生異常處理方式和redis 事務(wù)一致,可以說這兩個東西是一樣的,但是lua支持緩存,可以復(fù)用腳本,這個是原來的事務(wù)所沒有的

了解更多事務(wù)相關(guān)信息,看這個網(wǎng)站

如何在Redis中使用lua

在redis里面使用lua腳本主要用三個命令

  • eval
  • evalsha
  • script load
    eval用來直接執(zhí)行l(wèi)ua腳本,使用方式如下
EVAL script numkeys key [key ...] arg [arg ...]

> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

key代表要操作的rediskey
arg可以傳自定義的參數(shù)
numkeys用來確定key有幾個
script就是你寫的lua腳本
lua腳本里面使用KEYS[1]和ARGV[1]來獲取傳遞的key和arg

lua語法詳見lua教程

在用eval命令的時候,可以注意到每次都要把執(zhí)行的腳本發(fā)送過去,這樣勢必會有一定的網(wǎng)絡(luò)開銷,所以redis對lua腳本做了緩存,通過script load 和 evalsha實現(xiàn)
script load命令會在redis服務(wù)器緩存你的lua腳本,并且返回腳本內(nèi)容的SHA1校驗和,然后通過evalsha 傳遞SHA1校驗和來找到服務(wù)器緩存的腳本進行調(diào)用,這兩個命令的格式以及使用方式如下

SCRIPT LOAD script
EVALSHA sha1 numkeys key [key ...] arg [arg ...]

redis> SCRIPT LOAD "return 'hello moto'"
"232fd51614574cf0867b83d384a5e898cfd24e5a"

redis> EVALSHA 232fd51614574cf0867b83d384a5e898cfd24e5a 0
"hello moto"

SHA1有如下特性:不可以從消息摘要中復(fù)原信息;兩個不同的消息不會產(chǎn)生同樣的消息摘要,(但會有1x10 ^ 48分之一的機率出現(xiàn)相同的消息摘要,一般使用時忽略)。

spring-data-redis操作lua

上面講的是如何在redis控制臺調(diào)用lua腳本,現(xiàn)在我們來講下怎么在java里面調(diào)用
在java里面調(diào)用redis一般使用jedis,對于調(diào)用lua腳本來講,spring-data-redis包做的封裝使用起來更加方便,底層也是基于jiedis,所以我們這邊直接講spring-data-redis中的redisTemplate如何來調(diào)用lua
先導(dǎo)入依賴

<dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-redis</artifactId>
                <version>1.8.1.RELEASE</version>
</dependency>

然后我們使用StringRedisTemplate這個類來操作

@Resource
private StringRedisTemplate stringRedisTemplate;

public <T> T runLua(String fileClasspath, Class<T> returnType, List<String> keys, Object ... values){
        DefaultRedisScript<T> redisScript =new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(fileClasspath)));
        redisScript.setResultType(returnType);
        return stringRedisTemplate.execute(redisScript,keys,values);
    }

這個框架把lua腳本封裝成RedisScript對象,并且可以將lua腳本執(zhí)行的結(jié)果自動轉(zhuǎn)換為配置的java類型,然后只要直接調(diào)用execute方法即可
并且這個execute邏輯中封裝了evalsha的優(yōu)化,源碼如下

protected <T> T eval(RedisConnection connection, RedisScript<T> script, ReturnType returnType, int numKeys,
            byte[][] keysAndArgs, RedisSerializer<T> resultSerializer) {

        Object result;
        try {
            result = connection.evalSha(script.getSha1(), returnType, numKeys, keysAndArgs);
        } catch (Exception e) {

            if (!exceptionContainsNoScriptError(e)) {
                throw e instanceof RuntimeException ? (RuntimeException) e : new RedisSystemException(e.getMessage(), e);
            }

            result = connection.eval(scriptBytes(script), returnType, numKeys, keysAndArgs);
        }

        if (script.getResultType() == null) {
            return null;
        }

        return deserializeResult(resultSerializer, result);
    }

因為sha1的算法是通用的,所以在java客戶端可以提前算出SHA1校驗和,然后用evalsha來執(zhí)行腳本,如果SHA1對應(yīng)的腳本,那么還是用eval來執(zhí)行,eval執(zhí)行一次后,下次都可以直接調(diào)用evalsha了,減少網(wǎng)絡(luò)開銷

lua Debug

我們寫完一個lua腳本,lua和redis的數(shù)據(jù)類型是不一致的,存在一個轉(zhuǎn)換,并且如果遇到復(fù)雜邏輯的lua腳本,如果不能debug,只在自己腦子里面走這個邏輯,是不科學(xué)的,如果redis lua也提供了debug功能,要在redis客戶端執(zhí)行
在運行l(wèi)ua的eval,加上-ldb即可開啟debug功能,debug只支持eval命令

./redis-cli --ldb --eval /tmp/script.lua mykey somekey , arg1 arg2

然后提供了一些調(diào)試命令

lua debugger> help
Redis Lua debugger help:
[h]elp               Show this help.
[s]tep               Run current line and stop again.
[n]ext               Alias for step.
[c]continue          Run till next breakpoint.
[l]list              List source code around current line.
[l]list [line]       List source code around [line].
                     line = 0 means: current position.
[l]list [line] [ctx] In this form [ctx] specifies how many lines
                     to show before/after [line].
[w]hole              List all source code. Alias for 'list 1 1000000'.
[p]rint              Show all the local variables.
[p]rint <var>        Show the value of the specified variable.
                     Can also show global vars KEYS and ARGV.
[b]reak              Show all breakpoints.
[b]reak <line>       Add a breakpoint to the specified line.
[b]reak -<line>      Remove breakpoint from the specified line.
[b]reak 0            Remove all breakpoints.
[t]race              Show a backtrace.
[e]eval <code>       Execute some Lua code (in a different callframe).
[r]edis <cmd>        Execute a Redis command.
[m]axlen [len]       Trim logged Redis replies and Lua var dumps to len.
                     Specifying zero as <len> means unlimited.
[a]abort             Stop the execution of the script. In sync
                     mode dataset changes will be retained.

Debugger functions you can call from Lua scripts:
redis.debug()        Produce logs in the debugger console.
redis.breakpoint()   Stop execution as if there was a breakpoint in the
                     next line of code.

用redis.debug() 可以打日志
用redis.breakpoint()在lua腳本里打斷點
s和n都是跳到下行代碼
c是跳到下個斷點
list可以展示當前這條代碼前后的代碼

寫個簡單的lua腳本來測試下

local value1 = ARGV[1]
local value2 = ARGV[2]
redis.debug(value1)
redis.debug(value2)
if(value1>value2)
then
return "a"
else
return "b"
end
lua debug

可以看到用起來還是挺方便的

更多細節(jié)看官方教程

項目實戰(zhàn)

在我們項目中使用redis生成全局id,代碼如下

 @Autowired
    private RedisTemplate<String,Long> redisTemplate;


    public  String nextID(){
        String key = Prefix+simpleDateFormatThreadLocal.get().format(new Date());
        Long existedID = redisTemplate.opsForValue().get(key);
        if(existedID!=null){
            redisTemplate.opsForValue().set(key,existedID+1);
            return key+String.format("%04d",existedID+1);
        }else{
            redisTemplate.opsForValue().set(key,1L);
            return key+"0001";
        }
    }

這段代碼是存在問題的,在并發(fā)的情況下,get方法可以訪問到相同的key,就會出現(xiàn)id重復(fù)的問題,測試代碼如下

System.out.println("current:"+idGenerator.currentID());
        Integer threadSize =5;
        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for(int i =0 ;i<100;i++){
                    System.out.println(Thread.currentThread().getName()+":"+idGenerator.nextID());
                }
                countDownLatch.countDown();
            }
        };
        for(int i =0;i<threadSize;i++){
            new Thread(runnable,"Thread"+i).start();
        }
        countDownLatch.await();
        System.out.println("current:"+idGenerator.currentID());

當然這邊我們也可以使用樂觀鎖或者分布式鎖來實現(xiàn),但是鎖自旋的邏輯還是有潛在危險的
如果用lua來實現(xiàn),把這個阻塞動作放在redis服務(wù)器,那我們的代碼就會很健壯了
新建一個lua腳本

local key = KEYS[1]
local id = redis.call('get',key)
if(id == false)
then
    redis.call('set',key,1)
    return key.."0001"
else
    redis.call('set',key,id+1)
    return key..string.format('%04d',id + 1)
end

對應(yīng)調(diào)用java代碼如下

public String nextIDLua(){
        String key = Prefix+simpleDateFormatThreadLocal.get().format(new Date());
        DefaultRedisScript<String> redisScript =new DefaultRedisScript<>();
        redisScript.setLocation(new ClassPathResource("lua/genID.lua"));
        redisScript.setResultType(String.class);
        //System.out.println(redisScript.getSha1());
        return redisTemplate.execute(redisScript,(RedisSerializer<?>) redisTemplate.getKeySerializer(),(RedisSerializer<String>)redisTemplate.getKeySerializer(),Lists.newArrayList(key));
    }

把上面那個測試方法修改一下,進行測試
可以發(fā)現(xiàn),第一份代碼在多線程并發(fā)下是存在id重復(fù)問題的。
第二份代碼避免了這個問題

全套demo代碼地址請點擊

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • [TOC] 相關(guān)命令 EVAL SCRIPT_LOAD EVALSHA(執(zhí)行之前要求執(zhí)行過EVAL或者SCRIPT...
    志華_C閱讀 5,849評論 0 4
  • Lua 5.1 參考手冊 by Roberto Ierusalimschy, Luiz Henrique de F...
    蘇黎九歌閱讀 14,246評論 0 38
  • 本文將從Redis的基本特性入手,通過講述Redis的數(shù)據(jù)結(jié)構(gòu)和主要命令對Redis的基本能力進行直觀介紹。之后概...
    kelgon閱讀 61,659評論 23 625
  • 你的笑容依舊歷歷在目,我相信,我一輩子都不會忘記你的笑顏。——祈留 窗外下著很大的雨,可我的心里卻十分不好受,滴滴...
    花間夢月閱讀 213評論 0 1
  • 產(chǎn)假休完之后,選擇繼續(xù)上班,這是一段艱難的歷程,現(xiàn)在正猶豫不決中度過。 寶寶五個月時候,就回到了工作崗位,因為在公...
    趙淇淇閱讀 313評論 0 0

友情鏈接更多精彩內(nèi)容