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

可以看到用起來還是挺方便的
更多細節(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代碼地址請點擊