一個(gè)bug,差點(diǎn)損失幾萬
你好,我是猿java
最近遇到一個(gè)線上事故,差點(diǎn)損失好幾萬,故事是這樣的...
背景
在之前的文章里我們分析了 Redis中運(yùn)行 Lua腳本是如何保證原子性的。實(shí)際上,在我們的電商業(yè)務(wù)中也是使用 Redis + Lua來保證庫存的原子性操作,Redis是 Cluster集群部署,Lua腳本大致如下(本文的數(shù)據(jù)都經(jīng)過脫敏處理):
-- type都是java代碼中傳入的String值,sku為Long型
local function availableRealSaleCal(type,sku)
local key = formatKey(type, sku)
-- 銷售庫存 =(if 可售賣量 then 銷售庫存 = min(可售庫存,可售賣量)
-- else 銷售庫存 = 可售庫存 end)
local availableRealSale = 0;
local availableSale = redis.call('INCRBY', key..":AVAILABLE_SALE", 0);
local saleLimit = redis.call('HGET', key, 'sale_limit');
redis.call('SET', stocksKey .. ":AVAILABLE_REAL_SALE", availableRealSale);
return availableRealSale
end
-- 拼接庫存 key,比如:stock:sale:{13523551512}, 注意這里有一個(gè) {sku}
local function formatKey(type, sku)
return "stock:"..type..":"..":{"..sku.."}"
end;
在上面的 Lua腳本中,有 {sku}語法的使用,{}是在 Redis cluster 模式下特有的 Hash Tag,Redis 的哈希標(biāo)簽是一種特殊的語法,用于在執(zhí)行命令時(shí)將多個(gè) key 分組在一起。Hash Tag 由一對大括號(hào) {} 包圍,可以將其中的內(nèi)容視為一個(gè)整體來處理。
{}的主要用途包括:
- 強(qiáng)制將多個(gè) key 分組:在執(zhí)行命令時(shí),Redis 將哈希標(biāo)簽中的內(nèi)容視為一個(gè)整體,這樣就可以將多個(gè) key 分組在一起,使它們被視為同一個(gè)分片。這對于在分片集群中對多個(gè)相關(guān) key 執(zhí)行原子操作非常有用。
- 提高數(shù)據(jù)在集群中的分布均衡性:當(dāng)使用哈希標(biāo)簽時(shí),Redis 將根據(jù)標(biāo)簽中的內(nèi)容計(jì)算哈希槽(Hash Slot),而不是整個(gè) key。這樣可以確保具有相同標(biāo)簽的 key 被映射到相同的哈希槽,從而提高了數(shù)據(jù)在集群中的分布均衡性。
例如,假設(shè)有兩個(gè) key:{sku}:saleStock 和 {sku}:avalibleStock。如果不使用哈希標(biāo)簽,即sku:saleStock 和 sku:avalibleStock,這兩個(gè) key 將被視為不同的 key,可能被映射到不同的哈希槽。這樣,同一個(gè) sku的不同庫存可能被 hash到不同的 slot,但是,如果使用哈希標(biāo)簽 {sku},這樣,不管 {sku}拼接什么內(nèi)容,都會(huì)被視為同一個(gè)分片,從而確保它們被映射到相同的哈希槽,以保證原子性操作的一致性。
更多{}使用,可以參考redis的官方文檔。
發(fā)現(xiàn)問題
監(jiān)控報(bào)警,于是研發(fā)查排線上日志,如下:
Caused by: redis.clients.jedis.exceptions.JedisDataException:
ERR Error running script (call to f_1fbde7f097d74a7d77c854c93b308d36d164dbf9): @user_script:371: @user_script: 371:
Lua script attempted to access a non local key in a cluster node at redis.clients.jedis.Protocol.processError(Protocol.java:115)
看到這個(gè)錯(cuò)誤,一臉懵,代碼上線半年沒有出現(xiàn)過問題,怎么會(huì)突然出問題呢?
搜索問題
因?yàn)榈谝淮斡龅竭@個(gè)問題,于是 Google了一下,找到幾個(gè)類似的問題,大致意思差不多,下面給出一個(gè)stackover上面的例子,鏈接如下:stackoverflow相同的錯(cuò)誤,Lua 腳本摘要如下:
local f3=redis.call('HGET',KEYS[1],'1');
local f4=redis.call('HGET',f3,'1') ;
return f4;
對于錯(cuò)誤的解釋是:在 Lua中執(zhí)行多條語句,要保證key hash的 slot是同一個(gè),否則就會(huì)出現(xiàn)上面的錯(cuò)誤,比如:KEYS[1]和 f3 hash后不在同一個(gè) slot就會(huì)出現(xiàn)上述錯(cuò)誤。
定位問題
順著上面 Google 例子的思路,排查 {sku} hash后的值是否出現(xiàn)變更,線上跑的代碼,sku都是 14位的 Long,新上線的 sku 變成了 15位的 Long,會(huì)不會(huì)是長度變更導(dǎo)致問題?
于是,在中間件部門同事的配合下,找到了中間件的執(zhí)行l(wèi)og:
stockskey:stock:40-248-000008:{1.112422310001e+14}
太奇怪了,sku傳入的是 Long類型,現(xiàn)在變成{1.112422310001e+14},最后發(fā)現(xiàn)在 Redis中間件有個(gè)cjson的操作,當(dāng)傳入的 Long類型位數(shù)大于 14時(shí),會(huì)把 Long轉(zhuǎn)成科學(xué)計(jì)數(shù)法,導(dǎo)致{sku}改變了原有的語義。
解決問題
在 Java 端,把 sku 從 Long型轉(zhuǎn)成 String類型,再傳入Lua,這樣可以避免 Long被轉(zhuǎn)換成科學(xué)記數(shù)法。
事故定級(jí)
因?yàn)榧軜?gòu)中有小流量集群,每次有新 sku上線,都會(huì)在小流量集群上進(jìn)行灰度發(fā)布,所以受影響的面有限,最后定級(jí) P4,保住了 Q2的績效。
總結(jié)
- Redis中運(yùn)行 Lua腳本的確能保證原子性,而且經(jīng)過線上環(huán)境驗(yàn)證。
- 如果想對 Lua中的多個(gè) key hash到同一個(gè) slot,可以使用 Hash Tag 語法,Hash Tag 由一對大括號(hào) {} 包圍,可以將 {} 里面的內(nèi)容視為一個(gè)整體來處理。
- 特別注意,在很多場景 Long類型會(huì)被轉(zhuǎn)成科學(xué)記數(shù)法,記得曾經(jīng)和前端對接時(shí),出現(xiàn)過 Long 類型被截?cái)嗟膯栴}。
- 灰度發(fā)布在生產(chǎn)環(huán)境是個(gè)很不錯(cuò)的選擇,對于大的功能上線,可以局部是試錯(cuò)驗(yàn)證。
- 告警系統(tǒng)可以幫助我們更快的感知問題,對于大廠是標(biāo)配,對于中小公司,建議盡量去搭建告警系統(tǒng),即便簡陋一些也無所謂。