redis執行Lua腳本


https://blog.csdn.net/mytt_10566/article/details/99732583

jianshu.com/p/366d1b4f0d13

Redis Lua 這個技術,我之前就在關注,今天有空,我把項目中基於Redis實現的ID生成器改成用lua腳本實現,防止並發id沖突問題

Redis中使用Lua的好處

  • 減少網絡開銷。可以將多個請求通過腳本的形式一次發送,減少網絡時延
  • 原子操作。redis會將整個腳本作為一個整體執行,中間不會被其他命令插入。因此在編寫腳本的過程中無需擔心會出現競態條件,無需使用事務。
  • 復用。客戶端發送的腳步會永久存在redis中,這樣,其他客戶端可以復用這一腳本而不需要使用代碼完成相同的邏輯。

Redis Lua腳本與事務

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

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

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

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

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

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

至於那些在 EXEC 命令執行之后所產生的錯誤, 並沒有對它們進行特別處理: 即使事務中有某個/某些命令在執行時產生了錯誤, 事務中的其他命令仍然會繼續執行。

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

了解更多事務相關信息,看這個網站

如何在Redis中使用lua

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

  • eval
  • evalsha
  • script load
    eval用來直接執行lua腳本,使用方式如下
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可以傳自定義的參數
numkeys用來確定key有幾個
script就是你寫的lua腳本
lua腳本里面使用KEYS[1]和ARGV[1]來獲取傳遞的key和arg

lua語法詳見lua教程

在用eval命令的時候,可以注意到每次都要把執行的腳本發送過去,這樣勢必會有一定的網絡開銷,所以redis對lua腳本做了緩存,通過script load 和 evalsha實現
script load命令會在redis服務器緩存你的lua腳本,並且返回腳本內容的SHA1校驗和,然后通過evalsha 傳遞SHA1校驗和來找到服務器緩存的腳本進行調用,這兩個命令的格式以及使用方式如下

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

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

redis> EVALSHA 232fd51614574cf0867b83d384a5e898cfd24e5a 0
"hello moto"

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

spring-data-redis操作lua

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

<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腳本執行的結果自動轉換為配置的java類型,然后只要直接調用execute方法即可
並且這個execute邏輯中封裝了evalsha的優化,源碼如下

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來執行腳本,如果SHA1對應的腳本,那么還是用eval來執行,eval執行一次后,下次都可以直接調用evalsha了,減少網絡開銷

lua Debug

我們寫完一個lua腳本,lua和redis的數據類型是不一致的,存在一個轉換,並且如果遇到復雜邏輯的lua腳本,如果不能debug,只在自己腦子里面走這個邏輯,是不科學的,如果redis lua也提供了debug功能,要在redis客戶端執行
在運行lua的eval,加上-ldb即可開啟debug功能,debug只支持eval命令

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

然后提供了一些調試命令

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

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

更多細節看官方教程

項目實戰

在我們項目中使用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"; } } 

這段代碼是存在問題的,在並發的情況下,get方法可以訪問到相同的key,就會出現id重復的問題,測試代碼如下

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()); 

當然這邊我們也可以使用樂觀鎖或者分布式鎖來實現,但是鎖自旋的邏輯還是有潛在危險的
如果用lua來實現,把這個阻塞動作放在redis服務器,那我們的代碼就會很健壯了
新建一個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

對應調用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)); } 

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

全套demo代碼地址請點擊


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM