掌握 Redis 事務(wù),提升數(shù)據(jù)處理效率的必備秘籍
在實(shí)際的軟件開發(fā)項(xiàng)目中,我們經(jīng)常會遇到需要對數(shù)據(jù)進(jìn)行一系列連續(xù)操作的情況,而且這些操作必須作為一個整體要么全部成功,要么全部失敗,以保證數(shù)據(jù)的一致性。比如在電商系統(tǒng)中,下單、扣庫存、記錄訂單信息等操作需要作為一個不可分割的整體來執(zhí)行。
Redis作為一款常用的數(shù)據(jù)庫,其事務(wù)功能就為解決這類問題提供了有力的支持。那么,如何在項(xiàng)目中正確、高效地使用Redis事務(wù)呢?
一、redis事務(wù)的基本概念
1. redis事務(wù)的基本概念
redis的事務(wù)是一個單獨(dú)隔離的操作,它會將一系列指令按需排隊(duì)并順序執(zhí)行,期間不會被其他客戶端的指令插隊(duì),所以redis事務(wù)是保證組合命令的原子性。
redis的事務(wù)指令有3個關(guān)鍵字,分別是:
- multi:開啟事務(wù)
- exec:執(zhí)行事務(wù)
- discard:取消事務(wù)
通過multi,當(dāng)前客戶端就會開啟事務(wù),后續(xù)用戶鍵入的都指令都會保證到隊(duì)列中暫不執(zhí)行,當(dāng)用戶鍵入exec后,這些指令都會按順序執(zhí)行。 需要注意的是,若開啟multi后輸入若干指令,客戶端輸入discard,則之前的指令通通取消執(zhí)行。
2. 事務(wù)基礎(chǔ)操作示例
如上所示,事務(wù)本質(zhì)就是開啟、入隊(duì)、提交,接下來我們就來簡單演示一下,打開客戶端首先開啟事務(wù):
# 開啟事務(wù)
127.0.0.1:6379> MULTI
OK
然后將需要執(zhí)行的操作提交:
# 將兩個指令組隊(duì)
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> set k2 v2
QUEUED
完成后,我們就可以通過exec指令提交并執(zhí)行:
# 執(zhí)行兩個指令
127.0.0.1:6379(TX)> EXEC
1) OK
2) OK
最后查看執(zhí)行驗(yàn)證一下結(jié)果:
# 查看執(zhí)行結(jié)果
127.0.0.1:6379> keys *
1) "k1"
2) "k2"
二、詳解redis事務(wù)中的原子性
1. 組隊(duì)時錯誤
redis事務(wù)中的錯誤分別以下兩種:
- 組隊(duì)時錯誤
- 執(zhí)行命令時錯誤
我們先來說說組隊(duì)時錯誤的指令,上文我們已經(jīng)說過,redis事務(wù)開啟后提交的指令都會存到隊(duì)列中,這也就意味著在指令提交階段redis是可以感知到語法上的錯誤,所以在組隊(duì)時錯誤,redis一旦感知到錯誤,這些指令都不會執(zhí)行:
# 開啟事務(wù)
127.0.0.1:6379> MULTI
OK
# 指令入隊(duì)
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> set k2 v2
QUEUED
127.0.0.1:6379(TX)> set k33
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379(TX)> set k4 v4
QUEUED
# 執(zhí)行指令
127.0.0.1:6379(TX)> exec
(error) EXECABORT Transaction discarded because of previous errors.
# 指令并沒有被執(zhí)行
127.0.0.1:6379> keys *
(empty array)
127.0.0.1:6379>
這一點(diǎn)我們也可以從源碼的角度分析,redis會為每一個redis客戶端分配一個結(jié)構(gòu)體維護(hù)其內(nèi)部信息,這其中flag字段就代表著客戶端各種狀態(tài)標(biāo)識,這其中低3位就表示客戶端是否開啟事務(wù)標(biāo)識,如果1就代表開啟,反之代表未開啟:
我們都知道redis開啟事務(wù)需要multi指令,客戶端鍵入該指令之后,redis首先就會通過按位與判斷這個二進(jìn)制為是否被標(biāo)識為1,如果是則說明已經(jīng)開啟事務(wù),直接拋出嵌套事務(wù)異常告知客戶端不可重復(fù)調(diào)用multi指令,反之通過或運(yùn)算將其設(shè)置為1:
對應(yīng)的我們給出multi指令的源碼實(shí)現(xiàn)multiCommand,邏輯和筆者說明的一致解:
void multiCommand(redisClient *c) {
//REDIS_MULTI值為1<<3 如果按位與發(fā)現(xiàn)當(dāng)前客戶端已經(jīng)被標(biāo)識為開啟事務(wù),則直接跑錯事務(wù)不可嵌套的異常
if (c->flags & REDIS_MULTI) {
addReplyError(c,"MULTI calls can not be nested");
return;
}
//REDIS_MULTI值為1<<3 通過 | 符號將低3位標(biāo)識為1,意為開啟事務(wù)
c->flags |= REDIS_MULTI;
addReply(c,shared.ok);
}
后續(xù)用戶的指令提交處理都會走到公用處理函數(shù)processCommand,一旦感知到某條指令處理異常,redis就會將客戶端標(biāo)識flag標(biāo)記為臟事務(wù)REDIS_DIRTY_EXEC,后續(xù)指令提交時如果發(fā)現(xiàn)這個標(biāo)志位為1:
對應(yīng)我們給出所有指令提交前的通用邏輯函數(shù)processCommand,可以看到如果服務(wù)端感知到指令的指令參數(shù)不一致等異常就會調(diào)用flagTransaction將事務(wù)標(biāo)記為臟:
int processCommand(redisClient *c) {
//.......
c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
if (!c->cmd) {
//......
} else if ((c->cmd->arity > 0 && c->cmd->arity != c->argc) ||
(c->argc < -c->cmd->arity)) {//檢查參數(shù)數(shù)和命令表配置是否一致
//如果發(fā)現(xiàn)不一致則將客戶端flags標(biāo)識標(biāo)記上REDIS_DIRTY_EXEC標(biāo)識當(dāng)前事務(wù)是臟事務(wù)
flagTransaction(c);
addReplyErrorFormat(c,"wrong number of arguments for '%s' command",
c->cmd->name);
return REDIS_OK;
}
//......
}
void flagTransaction(redisClient *c) {
//如果開啟事務(wù)則將flags標(biāo)記上REDIS_DIRTY_EXEC,標(biāo)識當(dāng)前事務(wù)已臟
if (c->flags & REDIS_MULTI)
c->flags |= REDIS_DIRTY_EXEC;
}
有了上述的基礎(chǔ),我們執(zhí)行的exec就會通過判斷flags查看是否被標(biāo)記為REDIS_DIRTY_EXEC ,如果是則調(diào)用discardTransaction也就是discard清除隊(duì)列中的指令不執(zhí)行:
void execCommand(redisClient *c) {
//......
//如果發(fā)現(xiàn)標(biāo)識標(biāo)記為REDIS_DIRTY_EXEC,則調(diào)用 discardTransaction釋放掉事務(wù)隊(duì)列的指令不執(zhí)行
if (c->flags & (REDIS_DIRTY_CAS|REDIS_DIRTY_EXEC)) {
addReply(c, c->flags & REDIS_DIRTY_EXEC ? shared.execaborterr :
shared.nullmultibulk);
discardTransaction(c);
goto handle_monitor;
}
//......
}
來小結(jié)一下,redis組隊(duì)時異?;貪L的底層實(shí)現(xiàn):
- multi開啟事務(wù)
- 提交指令,如果發(fā)現(xiàn)指令異常則將當(dāng)前客戶端事務(wù)標(biāo)記為臟事務(wù)
- 調(diào)用exec時判斷客戶端標(biāo)識,如果包含臟標(biāo)記則清除事務(wù)隊(duì)列中的指令不執(zhí)行
2. 執(zhí)行時錯誤
有了上述基礎(chǔ)我們就很好理解執(zhí)行時錯誤了,執(zhí)行時錯誤比較特殊,他在按序處理所有指令,即時遇到錯誤就按正常流程處理繼續(xù)執(zhí)行下去,如下示例所示,可以看到我們將k1對應(yīng)的value是字符串類型,第二條指令執(zhí)行錯誤后,k2還是正常設(shè)置進(jìn)去了:
# 開啟事務(wù)
127.0.0.1:6379> MULTI
OK
# 設(shè)置字符串k1 v1
127.0.0.1:6379(TX)> set k1 v1
QUEUED
# 設(shè)置v1進(jìn)行自增,此時redis無法感知到這個異常
127.0.0.1:6379(TX)> INCR k1
QUEUED
# 正常鍵值對設(shè)置
127.0.0.1:6379(TX)> set k2 v2
QUEUED
# 提交執(zhí)行,1、3指令執(zhí)行成功
127.0.0.1:6379(TX)> EXEC
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
# 即使指令2失敗,指令3還是正常提交
127.0.0.1:6379> keys *
1) "k1"
2) "k2"
127.0.0.1:6379>
三、詳解redis事務(wù)中的樂觀鎖
1. 為什么redis需要事務(wù)
通過redis事務(wù)解決需要高性能且需要保證原子性的符合指令操作,最經(jīng)典的就是秒殺場景,如下圖,假設(shè)一個秒殺活動中有3個用戶,同時通過get指令發(fā)現(xiàn)庫存剩下1,全部通過原子扣減指令進(jìn)行扣減,導(dǎo)致超賣:
常見的解決方案有悲觀鎖和樂觀鎖,悲觀鎖(Pessimistic Lock)的原理是認(rèn)為自己操作的數(shù)據(jù)很可能會被他人修改,所以對臨界資源操作都持有悲觀的態(tài)度,每次進(jìn)行操作前都會對數(shù)據(jù)上鎖保證互斥,常見的關(guān)系型數(shù)據(jù)庫MySQL的行鎖、表鎖等都是基于這種鎖機(jī)制:
我們再來說說樂觀鎖(Optimistic Lock),該鎖的總是樂觀的認(rèn)為自己操作的數(shù)據(jù)不會被他人修改,進(jìn)行修改操作時不會針對臨界資源上鎖,而是修改的時候判斷一下當(dāng)前去數(shù)據(jù)版本號和修改的數(shù)據(jù)是否一致,通過比對版本號是否一致判斷是否被人修改,只要版本號一致當(dāng)前線程修改操作就會生效,redis中的watch關(guān)鍵字和jdk下的JUC包下的原子類就是采用這種工作機(jī)制:
2. redis事務(wù)樂觀鎖使用示例
這里我們就演示一下redis樂觀鎖的實(shí)現(xiàn),原理比較簡單,通過watch指令監(jiān)聽事務(wù)操作要操作的一個或者多個key值,當(dāng)用戶提交修改事務(wù)時,watch指令沒有檢測到key發(fā)生變化,則提交成功。
為方便演示,我們假設(shè)需要用事務(wù)操作名稱為key的數(shù)據(jù),我們首先初始化一下這個鍵值對:
# 設(shè)置key值
127.0.0.1:6379> set key 10
OK
然后開始watch指令監(jiān)聽這個key:
# 監(jiān)聽key
127.0.0.1:6379> WATCH key
OK
此時我們就可以開啟事務(wù)提交要執(zhí)行的操作:
# 開啟事務(wù)
127.0.0.1:6379> MULTI
OK
同理我們在這時候起一個客戶端2同樣執(zhí)行watch和multi操作:
# 監(jiān)聽key
127.0.0.1:6379> WATCH key
OK
# 開啟事務(wù)
127.0.0.1:6379> MULTI
OK
此時我們回到客戶端1執(zhí)行修改操作,可以看到因?yàn)閣atch到key沒有發(fā)生改變,修改操作成功:
# 指令加入隊(duì)列
127.0.0.1:6379(TX)> INCR key
QUEUED
# 執(zhí)行指令,可以看到執(zhí)行成功,修改了一條數(shù)據(jù),值被更新為11
127.0.0.1:6379(TX)> EXEC
1) (integer) 11
此時我們回到客戶端2提交指令并提交,可以看到提交結(jié)果失敗了,返回nil:
127.0.0.1:6379(TX)> INCR key
QUEUED
127.0.0.1:6379(TX)> exec
(nil)
這里我們也從源碼的角度解釋一下redis對于watch樂觀鎖的實(shí)現(xiàn),如上操作,當(dāng)我們客戶端鍵入watch指令時監(jiān)控key時,redis就會將當(dāng)前客戶端的信息掛到一個watched_keys的字典中,用key作為鍵,客戶端信息作為value追加到這個key的鏈表中。
我們客戶端1提交時,因?yàn)橹皼]有客戶端進(jìn)行修改,所以成功提交修改操作,并將watched_keys中監(jiān)聽key的所有客戶端的flags標(biāo)識為已被CAS修改即枚舉變量REDIS_DIRTY_CAS數(shù)值為1<<5。 然后客戶端2進(jìn)行修改操作時,看到自己的flags被修改為REDIS_DIRTY_CAS就知道了當(dāng)前key被人修改了,所以樂觀修改操作失?。?/p>
對應(yīng)源碼如下,當(dāng)客戶端1執(zhí)行exec時發(fā)現(xiàn)監(jiān)聽的key沒有被人修改,執(zhí)行incr操作之后,就會走到下面這個方法touchWatchedKey將watched_keys中監(jiān)聽key的客戶端標(biāo)識標(biāo)記為REDIS_DIRTY_CAS,告知當(dāng)前這個key已被我們修改:
void touchWatchedKey(redisDb *db, robj *key) {
//......
//從watched_keys找到監(jiān)聽當(dāng)前key的所有客戶端
clients = dictFetchValue(db->watched_keys, key);
//......
//遍歷訂閱這個key的所有客戶端
listRewind(clients,&li);
while((ln = listNext(&li))) {
redisClient *c = listNodeValue(ln);
//標(biāo)識為REDIS_DIRTY_CAS
c->flags |= REDIS_DIRTY_CAS;
}
}
所以當(dāng)客戶端2的執(zhí)行exec時,調(diào)用來到了execCommand,當(dāng)他發(fā)現(xiàn)自己的標(biāo)識即flags字段被客戶端1標(biāo)記為REDIS_DIRTY_CAS,就知道當(dāng)前key被人修改了,于是就執(zhí)行discard取消執(zhí)行當(dāng)前指令:
void execCommand(redisClient *c) {
//......
//如果發(fā)現(xiàn)標(biāo)識標(biāo)記為REDIS_DIRTY_EXEC或REDIS_DIRTY_CAS(當(dāng)前watch的key被人修改),則調(diào)用 discardTransaction釋放掉事務(wù)隊(duì)列的指令不執(zhí)行
if (c->flags & (REDIS_DIRTY_CAS|REDIS_DIRTY_EXEC)) {
addReply(c, c->flags & REDIS_DIRTY_EXEC ? shared.execaborterr :
//執(zhí)行discard操作清除當(dāng)前客戶端提交的執(zhí)行,且不執(zhí)行 shared.nullmultibulk);
discardTransaction(c);
goto handle_monitor;
}
//......
四、詳解redis事務(wù)的一些常見問題
1. 為什么redis不支持事務(wù)回滾
redis實(shí)際上是支持事務(wù)回滾的,只不過這種回滾是僅僅支持組隊(duì)時的異常,只有組隊(duì)時感知到指令錯誤,redis服務(wù)端才會標(biāo)記異常,后續(xù)執(zhí)行exec時就會將提交隊(duì)列的指令清除且不執(zhí)行,由此原子性,對應(yīng)的我們也有在上面的源碼給出解釋說明。
2. 如何理解redis的事務(wù)與ACID
(1) 原子性: redis設(shè)計(jì)者認(rèn)為他們是支持原子性的,因?yàn)樵有缘母拍钍?所有指令要么全部執(zhí)行,要么全部不執(zhí)行,只要客戶端提交的指令能夠在組隊(duì)階段被感知,它就能做到指令操作的原子性。
(2) 一致性: 針對數(shù)據(jù)的一致性,我們從3種情況進(jìn)行討論:
- 組隊(duì)階段:如果在事務(wù)組隊(duì)階段感知到異常,redis會主動事務(wù)中的指令且不執(zhí)行,可以保證一致性。
- 執(zhí)行時異常:在事務(wù)執(zhí)行階段出現(xiàn)異常,redis還是會順序執(zhí)行后續(xù)的指令,一致性就會被破壞
- 事務(wù)提交前redis宕機(jī):如果開啟了rdb或者aof持久化機(jī)制,可以在服務(wù)重啟時重新加載提交到隊(duì)列中的數(shù)據(jù),保證一致性。
(3) 隔離性: 隔離性要求避免所有的客戶端事務(wù)操作并發(fā)交叉執(zhí)行時導(dǎo)致數(shù)據(jù)不一致問題,如上樂觀鎖的說明,我們可以通過watch關(guān)鍵字監(jiān)聽key的變化保證事務(wù)提交時感知到其他客戶端的修改,如果發(fā)生修改就不提交事務(wù),由此避免隔離性遭到破壞。
(4) 持久性: 持久性的定義為事務(wù)處理結(jié)束后,對數(shù)據(jù)的修改就是永久的,即便系統(tǒng)故障也不會丟失。),考慮到性能問題,redis無論rdb還是aof都是異步持久化,所以并不能保證持久性。
3. Redis事務(wù)的其他實(shí)現(xiàn)方式了解過嘛?
基于lua腳本可以保證redis指令一次性執(zhí)按順序執(zhí)行完成,并且不會被其他客戶端打斷,但是這種方式卻無法實(shí)現(xiàn)事務(wù)回滾,所以我們可以需要在lua腳本的實(shí)現(xiàn)上進(jìn)行響應(yīng)的處理。
4. Redis事務(wù)三特性是什么?
- 單獨(dú)的隔離操作:事務(wù)中的命令都會序列化并且按序執(zhí)行,執(zhí)行過程中不會被其他客戶端的指令打斷。
- 沒有隔離級別的概念:事務(wù)提交前所有指令都不會被執(zhí)行。
- 無原子性:上文示例已經(jīng)演示過,執(zhí)行時出錯某段指令,事務(wù)過程中的指令仍然會生效。
5. 如何使用 Redis 事務(wù)?
Redis 可以通過 MULTI,EXEC,DISCARD 和 WATCH 等命令來實(shí)現(xiàn)事務(wù)(transaction)功能。
6. 如何解決 Redis 事務(wù)的缺陷?
從上文我們看出基于redis事務(wù)進(jìn)行秒殺方面的需求時會出現(xiàn)庫存遺留問題,這就是redis事務(wù)樂觀鎖機(jī)制的缺陷。 為了保證所有事務(wù)都能一次性的執(zhí)行,我們可以使用lua腳本更快(lua腳本可以輕易調(diào)用C語言庫函數(shù)以及被C語言直接調(diào)用)、更有效(基于lua腳本可以保證指令一次性被執(zhí)行不會被其他線程打斷),但是這種方案不支持回滾。