使用Redis過程中,可能會遇到各種問題。以下是一些常見問題及其解決方案:
一、 緩存雪崩(Cache Avalanche)
問題描述: Redis 雪崩(Redis Cache Avalanche)是指當(dāng)緩存中大量的緩存失效或者突然不可用時,大量請求直接打到后端數(shù)據(jù)庫,導(dǎo)致數(shù)據(jù)庫壓力劇增,甚至可能崩潰的現(xiàn)象。
這個問題主要出現(xiàn)在高并發(fā)系統(tǒng)中,當(dāng)大量緩存數(shù)據(jù)在同一時間過期或者緩存服務(wù)器宕機(jī)時尤為嚴(yán)重。
Redis 雪崩的常見原因:
解決方案:
為了防止Redis緩存雪崩(Cache Avalanche)問題,可以設(shè)計(jì)一套多層次的防護(hù)方案架構(gòu)。
以下是一個綜合性的方案架構(gòu),涵蓋了多種技術(shù)和策略來應(yīng)對可能的雪崩情況:
緩存雪崩
設(shè)置緩存數(shù)據(jù)的過期時間時,加入一定的隨機(jī)性,避免大量緩存數(shù)據(jù)在同一時間過期。
示例代碼:
import random
import redis
cache_expire_time=3600 # 基礎(chǔ)過期時間,單位為秒
random_expire_time=random.randint(0, 600) # 隨機(jī)增加0到600秒的時間
total_expire_time=cache_expire_time + random_expire_time
redis_client.setex(key, total_expire_time, value)
對一些非常熱點(diǎn)的數(shù)據(jù),設(shè)置永不過期,并在后臺定時刷新這些數(shù)據(jù)。
示例代碼:
while True:
# 獲取熱點(diǎn)數(shù)據(jù)
value=fetch_hot_data_from_db()
redis_client.set(key, value) # 設(shè)置不過期的緩存
time.sleep(refresh_interval) # 定時刷新
在應(yīng)用層和分布式緩存之間增加本地緩存層,進(jìn)一步減少對后端數(shù)據(jù)庫的直接訪問。
示例架構(gòu):
[用戶請求] -> [本地緩存] -> [分布式緩存(Redis)] -> [后端數(shù)據(jù)庫]
在緩存失效時,通過加鎖機(jī)制來控制只有一個線程去加載數(shù)據(jù)和更新緩存,其他線程等待鎖釋放后再訪問緩存。
示例代碼:
import threading
lock=threading.Lock()
def get_data(key):
value=redis_client.get(key)
if value is None:
with lock:
# 再次檢查緩存,防止多個線程進(jìn)入
value=redis_client.get(key)
if value is None:
# 加載數(shù)據(jù)并更新緩存
value=fetch_data_from_db()
redis_client.setex(key, expire_time, value)
return value
在緩存失效的瞬間,將多個對同一數(shù)據(jù)的請求合并為一個請求,減少對數(shù)據(jù)庫的訪問次數(shù)。
示例代碼:
pending_requests={}
def get_data(key):
if key in pending_requests:
return pending_requests[key].result()
else:
future=concurrent.futures.Future()
pending_requests[key]=future
try:
value=redis_client.get(key)
if value is None:
value=fetch_data_from_db()
redis_client.setex(key, expire_time, value)
future.set_result(value)
finally:
del pending_requests[key]
return future.result()
在系統(tǒng)啟動或定期運(yùn)行時,將一部分常用或重要的數(shù)據(jù)預(yù)先加載到緩存中,避免首次請求未命中緩存。
示例代碼:
def cache_warming():
hot_keys=get_hot_keys_from_db()
for key in hot_keys:
value=fetch_data_from_db(key)
redis_client.setex(key, expire_time, value)
# 在系統(tǒng)啟動時或定期調(diào)用
cache_warming()
在緩存失效的情況下,使用限流機(jī)制保護(hù)數(shù)據(jù)庫,或者降級處理,返回默認(rèn)值或提示用戶稍后重試。
示例代碼:
def get_data_with_rate_limit(key):
if is_rate_limited():
return get_default_value()
try:
value=redis_client.get(key)
if value is None:
value=fetch_data_from_db()
redis_client.setex(key, expire_time, value)
return value
except Exception:
return get_default_value()
對緩存命中率、數(shù)據(jù)庫訪問量等關(guān)鍵指標(biāo)進(jìn)行監(jiān)控,并設(shè)置報(bào)警閾值,及時發(fā)現(xiàn)和處理潛在問題。
示例架構(gòu):
二、 緩存擊穿(Cache Breakdown)
問題描述: 緩存中的熱點(diǎn)數(shù)據(jù)在失效的瞬間,有大量請求并發(fā)地訪問該數(shù)據(jù),直接打到后端數(shù)據(jù)庫,造成數(shù)據(jù)庫壓力驟增。
解決方案:
三、 緩存穿透(Cache Penetration)
問題描述: 查詢的數(shù)據(jù)既不在緩存中,也不在數(shù)據(jù)庫中,每次請求都會直接訪問數(shù)據(jù)庫,給數(shù)據(jù)庫帶來很大壓力。
解決方案:
為了防止Redis緩存穿透(Cache Penetration)問題,可以設(shè)計(jì)一個全面的解決方案架構(gòu)。以下是一個詳細(xì)的方案架構(gòu),涵蓋了多種技術(shù)和策略來應(yīng)對緩存穿透問題:
緩存穿透
對于查詢數(shù)據(jù)庫后確認(rèn)不存在的數(shù)據(jù),將空結(jié)果(如null或特定的占位符)緩存起來,設(shè)置一個短暫的過期時間,以避免頻繁查詢同一個不存在的數(shù)據(jù)。
示例代碼:
def get_data(key):
value=redis_client.get(key)
if value is None:
value=fetch_data_from_db(key)
if value is None:
redis_client.setex(key, 60, "NULL") # 緩存空值1分鐘
else:
redis_client.setex(key, 3600, value) # 正常數(shù)據(jù)緩存1小時
elif value=="NULL":
return None
return value
在緩存層前增加布隆過濾器,用于快速判斷一個數(shù)據(jù)是否存在,從而攔截對不存在數(shù)據(jù)的查詢請求。
示例架構(gòu):
from pybloom_live import BloomFilter
# 初始化布隆過濾器
bloom=BloomFilter(capacity=100000, error_rate=0.001)
# 在數(shù)據(jù)寫入數(shù)據(jù)庫時更新布隆過濾器
def add_to_bloom_filter(key):
bloom.add(key)
# 查詢時使用布隆過濾器
def get_data(key):
if key not in bloom:
return None
value=redis_client.get(key)
if value is None:
value=fetch_data_from_db(key)
if value is None:
redis_client.setex(key, 60, "NULL")
else:
redis_client.setex(key, 3600, value)
elif value=="NULL":
return None
return value
對請求的參數(shù)進(jìn)行合理性校驗(yàn),防止惡意請求進(jìn)入系統(tǒng)。
示例代碼:
def validate_request_params(params):
if not params.get('key'):
raise ValueError("Invalid parameters")
# 其他參數(shù)校驗(yàn)邏輯
return True
def get_data(params):
if validate_request_params(params):
key=params['key']
return fetch_data(key)
return None
對請求進(jìn)行限流,防止短時間內(nèi)大量惡意請求對系統(tǒng)造成沖擊。
示例代碼:
from ratelimit import limits, sleep_and_retry
@sleep_and_retry
@limits(calls=100, period=60)
def fetch_data(key):
# 數(shù)據(jù)獲取邏輯
return get_data_from_cache_or_db(key)
def get_data(key):
try:
return fetch_data(key)
except Exception as e:
return get_default_value()
在系統(tǒng)啟動或定期運(yùn)行時,將一部分常用或重要的數(shù)據(jù)預(yù)先加載到緩存中,避免首次請求未命中緩存。
示例代碼:
def cache_warming():
hot_keys=get_hot_keys_from_db()
for key in hot_keys:
value=fetch_data_from_db(key)
if value is not None:
redis_client.setex(key, 3600, value)
# 在系統(tǒng)啟動時或定期調(diào)用
cache_warming()
在緩存未命中的情況下,增加數(shù)據(jù)庫訪問的保護(hù)機(jī)制,防止數(shù)據(jù)庫被瞬時大量請求壓垮。
示例代碼:
import threading
lock=threading.Lock()
def get_data(key):
value=redis_client.get(key)
if value is None:
with lock:
# 再次檢查緩存,防止多個線程進(jìn)入
value=redis_client.get(key)
if value is None:
value=fetch_data_from_db(key)
if value is None:
redis_client.setex(key, 60, "NULL")
else:
redis_client.setex(key, 3600, value)
elif value=="NULL":
return None
return value
對緩存命中率、數(shù)據(jù)庫訪問量等關(guān)鍵指標(biāo)進(jìn)行監(jiān)控,并設(shè)置報(bào)警閾值,及時發(fā)現(xiàn)和處理潛在問題。
示例架構(gòu):
通過以上多層次的防護(hù)方案架構(gòu),可以有效減少Redis緩存穿透的發(fā)生,提高系統(tǒng)的穩(wěn)定性和性能。
四、 內(nèi)存不足
問題描述: 當(dāng)Redis使用的內(nèi)存超過系統(tǒng)限制或配置的最大內(nèi)存時,可能導(dǎo)致Redis進(jìn)程被殺掉或者數(shù)據(jù)被淘汰。
解決方案:
五、 數(shù)據(jù)持久化問題
問題描述: 在使用RDB或AOF進(jìn)行數(shù)據(jù)持久化時,可能會遇到數(shù)據(jù)丟失或文件損壞等問題。
解決方案:
六、 性能問題
問題描述: 在高并發(fā)或大數(shù)據(jù)量場景下,Redis可能出現(xiàn)性能瓶頸。
解決方案:
七、數(shù)據(jù)一致性問題
問題描述: 在主從復(fù)制或分布式環(huán)境下,可能會出現(xiàn)數(shù)據(jù)不一致的問題。
解決方案:
Split lock 是 CPU 為了支持跨 cache line 進(jìn)行原子內(nèi)存訪問而支持的內(nèi)存總線鎖。
有些處理器比如 ARM、RISC-V 不允許未對齊的內(nèi)存訪問,不會產(chǎn)生跨 cache line 的原子訪問,所以不會產(chǎn)生 split lock,而 X86 是支持的。
split lock 對開發(fā)者來說是很方便的,因?yàn)椴恍枰紤]內(nèi)存不對齊訪問的問題,但是這同時也是有代價的:一個產(chǎn)生 split lock 的指令會獨(dú)占內(nèi)存總線大約 1000 個時鐘周期,對比正常情況下的 ADD 指令約只需要小于 10 個時鐘周期,鎖住內(nèi)存總線導(dǎo)致其他 CPU 無法訪問內(nèi)存會嚴(yán)重影響系統(tǒng)性能。
因此 split lock 的檢測與處理就非常重要,現(xiàn)在的 CPU 支持檢測能力,檢測到如果在內(nèi)核態(tài)會直接 panic,在用戶態(tài)則會嘗試主動 sleep 來降低 split lock 產(chǎn)生的頻率,或者 kill 用戶態(tài)進(jìn)程,進(jìn)而緩解對內(nèi)存總線的爭搶。
在引入了虛擬化后,會嘗試在 Host 側(cè)處理,KVM 通知 QEMU 的 vCPU 線程主動 sleep 降低 split lock 產(chǎn)生的頻率,甚至 kill 虛擬機(jī)。以上的結(jié)論也只是截止目前 2022/4/19(下同)的情況,近 2 年社區(qū)仍對 split lock 的處理有不同的看法,處理方式也是改變了多次,所以以下的分析僅討論目前的情況。
我們假設(shè)一個最簡單的計(jì)算模型,一個 CPU(單核、沒有開啟 Hyper-threading、沒有 Cache),一塊內(nèi)存。上面運(yùn)行一個 C 程序在執(zhí)行i++,對應(yīng)的匯編代碼是add 1, i。
分析一下這里add指令的語義,需要兩個操作數(shù),源操作數(shù) SRC 和目的操作數(shù) DEST,實(shí)現(xiàn)的功能是DEST=DEST + SRC。這里 SRC 是立即數(shù) 1,DEST 是 i 的內(nèi)存地址,CPU 需要先在內(nèi)存中讀出 i 的內(nèi)容,然后加 1,最后把結(jié)果寫入 i 所在的內(nèi)存地址。總共產(chǎn)生了兩次串行的內(nèi)存操作。
如果計(jì)算架構(gòu)復(fù)雜一點(diǎn),有 2 個 CPU 核 CoreA 和 CoreB 的情況下,上面的i++代碼就不得不考慮數(shù)據(jù)一致性的問題:
如果 CoreA 正在向 i 的內(nèi)存地址中寫入時,CoreB 同時向 i 的內(nèi)存地址寫入怎么辦?
并發(fā)寫相同內(nèi)存地址其實(shí)很簡單,CPU 從硬件上保證了基礎(chǔ)內(nèi)存操作的原子性。
具體的操作有:
如果 CoreA 從內(nèi)存中讀出 i 后,寫入 i 所在內(nèi)存地址前這段時間內(nèi),CoreB 向 i 的內(nèi)存地址寫入數(shù)據(jù)怎么辦?
這種情況下會導(dǎo)致 CoreB 寫入的數(shù)據(jù)被 CoreA 后面再寫入的數(shù)據(jù)覆蓋掉,使 CoreB 的寫入數(shù)據(jù)丟失,而 CoreA 也不知道寫入的數(shù)據(jù)已經(jīng)在讀出后被更新過了。
為什么會出現(xiàn)這個問題呢?就是因?yàn)?ADD 指令不是原子操作,會產(chǎn)生兩次內(nèi)存操作。
那怎么解決這個問題呢?既然 ADD 指令在硬件上不是原子的,那么就從軟件上加鎖來實(shí)現(xiàn)原子操作,使 CoreB 的的內(nèi)存操作在 CoreA 的內(nèi)存操作完成前不能執(zhí)行。
對應(yīng)方法就是聲明指令前綴LOCK,匯編代碼變?yōu)?span style="color: #35B378; --tt-darkmode-color: #35B378;">lock add 1, i。
LOCK指令前綴聲明后,隨同執(zhí)行的指令會變?yōu)樵又噶睢T砭褪窃陔S同指令執(zhí)行期間,鎖住系統(tǒng)總線,禁止其他處理器進(jìn)行內(nèi)存操作,使其獨(dú)占內(nèi)存來實(shí)現(xiàn)原子操作。
下面舉幾個例子:
QEMU 中的函數(shù) qatomic_inc(ptr),把參數(shù) ptr 指向的內(nèi)存數(shù)據(jù)進(jìn)行進(jìn)行加 1。
#define qatomic_inc(ptr) ((void) __sync_fetch_and_add(ptr, 1))
原理是調(diào)用 GCC 內(nèi)置的__sync_fetch_and_add 函數(shù),我們手寫一個 C 程序,看下__sync_fetch_and_add 的匯編實(shí)現(xiàn)。
int main() {
int i=1;
int *p=&i;
while(1) {
__sync_fetch_and_add(p, 1);
}
return 0;
}
// add.s
.file "add.c"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $1, -12(%rbp)
leaq -12(%rbp), %rax
movq %rax, -8(%rbp)
.L2:
movq -8(%rbp), %rax
lock addl $1, (%rax)
jmp .L2
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Debian 6.3.0-18+deb9u1) 6.3.0 20170516"
.section .note.GNU-stack,"",@progbits
可以看到__sync_fetch_and_add 的匯編實(shí)現(xiàn)就是在 add 指令前聲明了 lock 指令前綴。
Kernel 中的 atomic_inc 函數(shù),把參數(shù) v 指向的內(nèi)存數(shù)據(jù)進(jìn)行進(jìn)行加 1。
static __always_inline void
atomic_inc(atomic_t *v)
{
instrument_atomic_read_write(v, sizeof(*v));
arch_atomic_inc(v);
}
static __always_inline void arch_atomic_inc(atomic_t *v)
{
asm volatile(LOCK_PREFIX "incl %0"
: "+m" (v->counter) :: "memory");
}
#define LOCK_PREFIX LOCK_PREFIX_HERE "\n\tlock; "
可以看到,同樣是聲明了 lock 指令前綴。
編程語言中的 CAS 接口為開發(fā)者提供了原子操作,實(shí)現(xiàn)無鎖機(jī)制。
// bool Cas(int32 *val, int32 old, int32 new)
// Atomically:
// if(*val==old){
// *val=new;
// return 1;
// } else
// return 0;
TEXT ·Cas(SB),NOSPLIT,$0-17
MOVQ ptr+0(FP), BX
MOVL old+8(FP), AX
MOVL new+12(FP), CX
LOCK
CMPXCHGL CX, 0(BX)
SETEQ ret+16(FP)
RET
inline jlong Atomic::cmpxchg (jlong exchange_value, volatile jlong* dest, jlong compare_value) {
bool mp=os::is_MP();
__asm__ __volatile__ (LOCK_IF_MP(%4) "cmpxchgq %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}
// Adding a lock prefix to an instruction on MP machine
#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "
可以看到,CAS 同樣是使用 lock 指令前綴來實(shí)現(xiàn)的,那么 lock 指令前綴具體是怎么實(shí)現(xiàn)的呢?
具體來說,代碼中的指令前面聲明了 LOCK 前綴指令后,處理器就會在指令運(yùn)行期間產(chǎn)生 LOCK#信號,使其他處理器不能通過總線訪問內(nèi)存。
我們嘗試從 8086 CPU 的引腳圖中管中窺豹,了解下 LOCK#信號的原理。
8086 CPU 存在一個 LOCK 引腳(圖中 29 號引腳),低電平有效。當(dāng)聲明 LOCK 指令前綴時,會拉低 LOCK 引腳電平,進(jìn)行 assert 操作,此時其他設(shè)備無法獲取系統(tǒng)總線的控制權(quán)。當(dāng) LOCK 指令修飾的指令執(zhí)行完成后,拉高 LOCK 引腳電平進(jìn)行 de-assert。
所以整個流程就清晰了,當(dāng)想要通過非原子指令(例如 add)實(shí)現(xiàn)原子操作時,編程時需要在指令前聲明 lock 指令前綴,運(yùn)行時 lock 指令前綴會被處理器識別出來,并產(chǎn)生 LOCK#信號,使其獨(dú)占內(nèi)存總線,而其他處理器則無法通過內(nèi)存總線訪問內(nèi)存,這樣就實(shí)現(xiàn)了原子操作。所以也就解決了上面的寫覆蓋問題了。
看起來很好,不過這樣又引入了一個新問題:
現(xiàn)在處理器的核越來越多,如果每個核都頻繁的產(chǎn)生 LOCK#信號,來獨(dú)占內(nèi)存總線,這樣其余的核不能訪問內(nèi)存,導(dǎo)致性能會有很大的下降,該怎么辦?
INTEL 為了優(yōu)化總線鎖導(dǎo)致的性能問題,在 P6 后的處理器上,引入了緩存鎖(cache locking)機(jī)制:通過緩存一致性協(xié)議保證多個 CPU 核訪問跨 cache line 的內(nèi)存地址的多次訪問的原子性與一致性,而不需要鎖內(nèi)存總線。
先以常見的 MESI 簡單介紹一下緩存一致性協(xié)議。MESI 分為四種狀態(tài):
MESI 協(xié)議狀態(tài)機(jī)如下:
狀態(tài)機(jī)的轉(zhuǎn)換基于兩種情況:
簡單來說,通過 MESI 協(xié)議,每個 CPU 不僅知道自身對 cache 的讀寫操作,還進(jìn)行總線嗅探(snooping),可以知道其他 CPU 對 cache 的的讀寫操作,所以除了自身對 cache 的修改也會根據(jù)其他 CPU 對 cache 的修改來改變 cache 的狀態(tài)。
緩存鎖是依賴緩存一致性協(xié)議來保證內(nèi)存訪問的原子性,因?yàn)榫彺嬉恢滦詤f(xié)議會阻止被多個 CPU 緩存的內(nèi)存地址被多個 CPU 同時修改。
下面我們以一個例子分析緩存鎖是如何基于 MESI 協(xié)議實(shí)現(xiàn)內(nèi)存讀寫的原子性。
我們還是假設(shè)有兩個 CPU Core,CoreA 與 CoreB 進(jìn)行分析。
注意最后一個操作步驟 4,CoreB 修改 cache 中的數(shù)據(jù)后,當(dāng) CoreA 想再次修改時,會被 CoreB 嗅探到,只有等 CoreB 的數(shù)據(jù)同步到主存與 CoreA 后,CoreA 才會進(jìn)行修改。
可以看到 CoreB 修改的數(shù)據(jù)沒有丟失,被同步給了 CoreA 與主存。并且實(shí)現(xiàn)上述的操作沒有鎖內(nèi)存總線,只是 CoreA 的修改操作被堵塞了一下,這相比鎖整個內(nèi)存總線是可控的。
上面是一個比較簡單的情況,兩個 CPU Core 的寫入是串行的。那么如果在操作步驟 2 后,CoreA 與 CoreB 同時下發(fā)寫請求呢?會產(chǎn)生兩個 Core 的 cache 都進(jìn)入 M 狀態(tài)嗎?
答案是否定的,MESI 協(xié)議保證了上面同時進(jìn)入 M 的情況不會發(fā)生。根據(jù) MESI 協(xié)議,一個 Core 的 PrWr 操作只能在其 cache 為 M 或 E 狀態(tài)時自由的執(zhí)行,如果是 S 狀態(tài),其他 Core 的 cache 必須先被設(shè)置為 I 狀態(tài),實(shí)現(xiàn)的方式是通過一個叫 Request For Ownership(RFO)的總線廣播進(jìn)行的,RFO 是一個總線事務(wù),如果兩個 Core 同時向總線進(jìn)行 RFO 廣播都想 Invalid 對方的 cache,總線會進(jìn)行仲裁,最終結(jié)果會是只有一個 Core 廣播成功,而另一個 Core 會失敗,其 cache 會被設(shè)置為 I 狀態(tài)。所以我們能看到,引入 cache 層后,原子操作由鎖內(nèi)存總線變?yōu)榱擞煽偩€仲裁來實(shí)現(xiàn)。
如果聲明了 LOCK 指令前綴,那么對應(yīng)的 cache 地址會被總線鎖定,在上面的例子中,其他 Core 在訪問時會等到指令執(zhí)行結(jié)束后再進(jìn)行訪問,也即變?yōu)榱舜胁僮鳎瑢?shí)現(xiàn)了對 cache 讀寫的原子性。
那么總結(jié)一下緩存鎖:在代碼指令前面聲明了 LOCK 指令前綴,想要原子訪問內(nèi)存數(shù)據(jù),如果內(nèi)存數(shù)據(jù)可以被緩存在 CPU 的 cache 中,運(yùn)行時通常不會在總線上產(chǎn)生 LOCK#信號,而是通過緩存一致性協(xié)議、總線仲裁機(jī)制與 cache 鎖定來阻止兩個或以上的 CPU 核,對同一塊地址的并發(fā)訪問。
那么是不是所有的總線鎖都可以被優(yōu)化為緩存鎖呢?答案是否定的,不能被優(yōu)化的情況就是 split lock。
由于緩存一致性協(xié)議的粒度是一個 cache line,當(dāng)原子操作的數(shù)據(jù)跨 cache line 時,依賴緩存鎖機(jī)制無法保證數(shù)據(jù)一致性,會退化為總線鎖來保證一致性,這種情況就是 split lock,split 也可以理解為訪存的 cache 被 split 為兩個 line。
比如有如下數(shù)據(jù)結(jié)構(gòu):
struct Data {
char padding[62]; // 62字節(jié)
int32_t value; // 4字節(jié)
} __attribute__((packed)) // 按實(shí)際字節(jié)對齊
被緩存到 cache line 大小為 64 字節(jié)的 cache 中時,value 成員會跨 cache line。
此時如果想要通過LOCK ADD 指令操作 Data 結(jié)構(gòu)中的 value 成員,就無法通過緩存鎖解決,只能走老路,鎖總線來保證數(shù)據(jù)一致性。
而鎖總線會引起嚴(yán)重的性能下降,訪存延遲增加百倍左右,如果是內(nèi)存密集型業(yè)務(wù),性能會下降 2 個數(shù)量級。所以在現(xiàn)代 X86 處理器中,要避免寫出會產(chǎn)生 split lock 的代碼,并有能力檢測出 Split lock 的產(chǎn)生。
回顧一下 Split lock 的產(chǎn)生條件:
因?yàn)樵硬僮魇潜容^基礎(chǔ)的操作,所以我們以數(shù)據(jù)跨 cache line 存儲為介入點(diǎn)進(jìn)行分析。
如果數(shù)據(jù)只存儲在一個 cache line 中,那就可以解決問題。
我們前面的數(shù)據(jù)結(jié)構(gòu)中有用到 __attribute__((packed)) 這個 GCC 的特性,表示不進(jìn)行內(nèi)存對齊優(yōu)化。
如果不引入 __attribute__((packed)),使用內(nèi)存對齊優(yōu)化時,編譯器會對內(nèi)存數(shù)據(jù)進(jìn)行填充,比如在 padding 后填入 2 字節(jié),使 value 的內(nèi)存地址可以被 4 字節(jié)整除,從而達(dá)到對齊。被緩存到 cache 中時 value 也就不會跨 cache line 了。
既然編譯器可以優(yōu)化后可以通過內(nèi)存對齊避免跨 cache line 訪問,為什么還要引入 __attribute__((packed))呢?
這是因?yàn)橥ㄟ^ __attribute__((packed)) 強(qiáng)制按數(shù)據(jù)結(jié)構(gòu)對齊,也有好處。比如基于數(shù)據(jù)結(jié)構(gòu)的網(wǎng)絡(luò)通信,不需要填充多余字節(jié)等。
我們在編寫代碼過程中,有以下幾點(diǎn)需要注意:
下面主要針對云環(huán)境,自底向上進(jìn)行分析。
當(dāng)嘗試 split lock 操作時會產(chǎn)生 Alignment Check (#AC) exception,當(dāng)獲取 bus lock 并執(zhí)行后會產(chǎn)生 Debug(#DB) trap。
硬件這里區(qū)分下了 split lock 與 bus lock:
概念上,split lock 是 bus lock 的一種,split lock 傾向于跨 cache line 訪問,bus lock 傾向鎖總線的操作。
發(fā)生 split lock 和 bus lock 時是否產(chǎn)生對應(yīng)的 exception,可以由特定的寄存器控制,下面是相關(guān)的控制寄存器。
2. IA32_DEBUGCTL:這個 MSR 的 bit 2,控制 bus lock 引起的#DB exception。
以 v5.17 版本為例進(jìn)行分析。當(dāng)前版本內(nèi)核支持一個相關(guān)啟動參數(shù) split_lock_detect,配置項(xiàng)和對應(yīng)功能如下:
實(shí)現(xiàn) split_lock_detect 主要分為 3 部分:配置、初始化、處理,下面我們逐項(xiàng)分析一下源碼:
-> start_kernel
-> sld_setup
-> split_lock_setup
-> __split_lock_setup
-> sld_state_setup
內(nèi)核啟動時,會先進(jìn)行 sld(split lock detect)的 setup。
static void __init __split_lock_setup(void)
{
if (!split_lock_verify_msr(false)) {
pr_info("MSR access failed: Disabled\n");
return;
}
rdmsrl(MSR_TEST_CTRL, msr_test_ctrl_cache);
if (!split_lock_verify_msr(true)) {
pr_info("MSR access failed: Disabled\n");
return;
}
/* Restore the MSR to its cached value. */
wrmsrl(MSR_TEST_CTRL, msr_test_ctrl_cache);
setup_force_cpu_cap(X86_FEATURE_SPLIT_LOCK_DETECT);
}
static bool split_lock_verify_msr(bool on)
{
u64 ctrl, tmp;
if (rdmsrl_safe(MSR_TEST_CTRL, &ctrl))
return false;
if (on)
ctrl |=MSR_TEST_CTRL_SPLIT_LOCK_DETECT;
else
ctrl &=~MSR_TEST_CTRL_SPLIT_LOCK_DETECT;
if (wrmsrl_safe(MSR_TEST_CTRL, ctrl))
return false;
rdmsrl(MSR_TEST_CTRL, tmp);
return ctrl==tmp;
}
#define MSR_TEST_CTRL 0x00000033
__split_lock_setup 中嘗試 enable/disable 33H MSR 進(jìn)行 verify,結(jié)束也并沒有 enable split lock #AC exception,而是僅留下一個全局變量 msr_test_ctrl_cache 當(dāng)作后面操作這個 MSR 的 cache 使用。
static void __init sld_state_setup(void)
{
enum split_lock_detect_state state=sld_warn; // 默認(rèn)配置
char arg[20];
int i, ret;
if (!boot_cpu_has(X86_FEATURE_SPLIT_LOCK_DETECT) &&
!boot_cpu_has(X86_FEATURE_BUS_LOCK_DETECT))
return;
ret=cmdline_find_option(boot_command_line, "split_lock_detect",
arg, sizeof(arg));
if (ret >=0) {
for (i=0; i < ARRAY_SIZE(sld_options); i++) {
if (match_option(arg, ret, sld_options[i].option)) {
state=sld_options[i].state;
break;
}
}
}
sld_state=state;
}
static inline bool match_option(const char *arg, int arglen, const char *opt)
{
int len=strlen(opt), ratelimit;
if (strncmp(arg, opt, len))
return false;
/*
* Min ratelimit is 1 bus lock/sec.
* Max ratelimit is 1000 bus locks/sec.
*/
if (sscanf(arg, "ratelimit:%d", &ratelimit)==1 &&
ratelimit > 0 && ratelimit <=1000) {
ratelimit_state_init(&bld_ratelimit, HZ, ratelimit);
ratelimit_set_flags(&bld_ratelimit, RATELIMIT_MSG_ON_RELEASE);
return true;
}
return len==arglen;
}
sld_state_setup 做的是解析出內(nèi)核啟動參數(shù) split_lock_detect 的配置(可以看到默認(rèn)的配置是 warn 級別),如果是 ratelimit 配置,會使用內(nèi)核的 ratelimit 庫初始化一個 bld_ratelimit 全局變量給 handle 階段用。
-> start_kernel
-> init_intel
-> split_lock_init
-> bus_lock_init
setup 完成基本的 verify 和獲取啟動參數(shù)配置后,會嘗試進(jìn)行硬件 enbale 操作。
static void split_lock_init(void)
{
/*
* #DB for bus lock handles ratelimit and #AC for split lock is
* disabled.
*/
if (sld_state==sld_ratelimit) {
split_lock_verify_msr(false);
return;
}
if (cpu_model_supports_sld)
split_lock_verify_msr(sld_state !=sld_off);
}
split lock 的 init 中,如果發(fā)現(xiàn)配置的參數(shù)是 ratelimit,會 disable split lock 的硬件檢測。其他非 off 參數(shù)(warn、fatal)則會 enable 硬件。
static void bus_lock_init(void)
{
u64 val;
/*
* Warn and fatal are handled by #AC for split lock if #AC for
* split lock is supported.
*/
if (!boot_cpu_has(X86_FEATURE_BUS_LOCK_DETECT) ||
(boot_cpu_has(X86_FEATURE_SPLIT_LOCK_DETECT) &&
(sld_state==sld_warn || sld_state==sld_fatal)) ||
sld_state==sld_off)
return;
/*
* Enable #DB for bus lock. All bus locks are handled in #DB except
* split locks are handled in #AC in the fatal case.
*/
rdmsrl(MSR_IA32_DEBUGCTLMSR, val);
val |=DEBUGCTLMSR_BUS_LOCK_DETECT;
wrmsrl(MSR_IA32_DEBUGCTLMSR, val);
}
#define DEBUGCTLMSR_BUS_LOCK_DETECT (1UL << 2)
bus lock 的 init 中,如果 CPU 不支持 bus lock 則不會 enable bus lock 的硬件檢測,如果 CPU 同時支持 split lock 與 bus lock 的硬件檢測并且配置參數(shù)還是 warn 或 fatal 時也不會 enable 硬件。
所以從代碼看 enable CPU 的 bus lock 檢測情況有兩種:一是 CPU 支持 bus lock 檢測的情況下并且配置參數(shù)指定為 ratelimt,二是 CPU 不支持 split lock 檢測但支持 bus lock 檢測并且配置參數(shù)非 off。
DEFINE_IDTENTRY_ERRORCODE(exc_alignment_check)
{
char *str="alignment check";
if (notify_die(DIE_TRAP, str, regs, error_code, X86_TRAP_AC, SIGBUS)==NOTIFY_STOP)
return;
if (!user_mode(regs)) // 如果不是用戶態(tài)
die("Split lock detected\n", regs, error_code);
local_irq_enable();
if (handle_user_split_lock(regs, error_code))
goto out;
do_trap(X86_TRAP_AC, SIGBUS, "alignment check", regs,
error_code, BUS_ADRALN, NULL);
out:
local_irq_disable();
}
bool handle_user_split_lock(struct pt_regs *regs, long error_code)
{
if ((regs->flags & X86_EFLAGS_AC) || sld_state==sld_fatal)
return false;
split_lock_warn(regs->ip);
return true;
}
static void split_lock_warn(unsigned long ip)
{
pr_warn_ratelimited("#AC: %s/%d took a split_lock trap at address: 0x%lx\n",
current->comm, current->pid, ip);
/*
* Disable the split lock detection for this task so it can make
* progress and set TIF_SLD so the detection is re-enabled via
* switch_to_sld() when the task is scheduled out.
*/
sld_update_msr(false);
set_tsk_thread_flag(current, TIF_SLD);
}
#AC exception 產(chǎn)生后,如果不是用戶態(tài),會調(diào)用 die,進(jìn)入 panic 流程。
如果是在用戶態(tài),那么配置為 fatal 時就會向當(dāng)前用戶態(tài)進(jìn)程發(fā)送 SIGBUS 信號,用戶態(tài)進(jìn)程不手動捕獲 SIGBUS 就會被 kill。
如果是用戶態(tài),配置為 warn 時,會打印一條警告日志并輸出當(dāng)前進(jìn)程信息,同時 disable split lock 的檢測,并通過設(shè)置當(dāng)前進(jìn)程 flags 的 TIF_SLD 位表示這個進(jìn)程已經(jīng)被檢測過一次了。
-> context_switch
-> __switch_to_xtra
void __switch_to_xtra(struct task_struct *prev_p, struct task_struct *next_p)
{
unsigned long tifp, tifn;
tifn=read_task_thread_flags(next_p);
tifp=read_task_thread_flags(prev_p);
if ((tifp ^ tifn) & _TIF_SLD)
switch_to_sld(tifn);
}
void switch_to_sld(unsigned long tifn)
{
sld_update_msr(!(tifn & _TIF_SLD));
}
在 context_switch 進(jìn)行進(jìn)程切換時,如果 prev 進(jìn)程和 next 進(jìn)程的 flags 中 TIF_SLD 位不同,那么就會進(jìn)行 split lock detect 的切換,切換的依據(jù)是 next 進(jìn)程的 TIF_SLD 位。
舉個例子,CPU 0 上進(jìn)程 A 在觸發(fā)了一次 split lock 的 warn 檢測后,CPU 0 的 split lock 檢測會被 disable,避免頻繁產(chǎn)生警告日志,進(jìn)程 A 的 flags 置位 TIF_SLD,進(jìn)程 A 執(zhí)行完成后,切換到進(jìn)程 B 運(yùn)行在 CPU 0 上,切換時由于 A 與 B 的 flags 的 TIF_SLD 位不同,我們就再根據(jù)進(jìn)程 B 的 flags 來 enable split lock 的檢測,進(jìn)程 B 執(zhí)行完成后,再次切換到進(jìn)程 A 運(yùn)行時,會再 disable split lock。
上面的機(jī)制就實(shí)現(xiàn)了每個進(jìn)程只 warn once 的需求。
static __always_inline void exc_debug_user(struct pt_regs *regs,
unsigned long dr6)
{
/* #DB for bus lock can only be triggered from userspace. */
if (dr6 & DR_BUS_LOCK)
handle_bus_lock(regs);
}
void handle_bus_lock(struct pt_regs *regs)
{
switch (sld_state) {
case sld_off:
break;
case sld_ratelimit:
/* Enforce no more than bld_ratelimit bus locks/sec. */
while (!__ratelimit(&bld_ratelimit))
msleep(20);
/* Warn on the bus lock. */
fallthrough;
case sld_warn:
pr_warn_ratelimited("#DB: %s/%d took a bus_lock trap at address: 0x%lx\n",
current->comm, current->pid, regs->ip);
break;
case sld_fatal:
force_sig_fault(SIGBUS, BUS_ADRALN, NULL);
break;
}
}
#DB trap 產(chǎn)生后,warn 和 fatal 的流程和#AC 基本差不多,我們這里重點(diǎn)分析 ratelimit,這也是引入 bus lock 的一個強(qiáng)需求。
強(qiáng)制把 bus lock 產(chǎn)生的頻率降低到配置的 ratelimit。原理就是如果頻率超出設(shè)定,就直接 sleep 20 ms,直到頻率降下來(這里的頻率是整個系統(tǒng)產(chǎn)生 bus lock 的頻率)。
降頻過后通過編譯器的 fallthrough 流入 sld_warn case 產(chǎn)生一條警告日志。
前面的分析都是針對物理機(jī)上的內(nèi)核與用戶態(tài)程序(VMX root mode)。在虛擬化環(huán)境中需要再考慮一些問題,比如如果 split lock 來自于 Guest 中,Host 如何檢測?怎么避免對其他 Guest 的影響?如果直接在 Host 上 enable bus lock ratelimit,可能會影響沒有準(zhǔn)備好的 Guest。如果直接把 split lock 的檢測開關(guān)暴露給 Guest,Host 或其他 Guest 怎么處理等等。
CPU 可以在 VMX mode 下支持 split lock 檢測的#AC trap,后面具體怎么做由 hypervisor 決定。大部分 hypervisor 會直接把 trap 轉(zhuǎn)發(fā)到 Guest 中,如果 Guest 沒有準(zhǔn)備好,可能會產(chǎn)生 crash。之前的 VMX 版本中就有這個問題。
因此首先就要讓 hypervisor 正確處理 trap。
下面是虛擬化環(huán)境對 split lock 與 bus lock 的整體處理流程圖:
Guest 中嘗試 split lock 操作,會 vm exit 到 kvm 中,如果硬件不支持 split lock 檢測或者為 legacy #AC,會把#AC 注入給 Guest 處理,如果硬件支持檢測,那么會根據(jù)配置產(chǎn)生警告,甚至嘗試 SIGBUS。
Guest 進(jìn)行 bus lock 后,會 vm exit 到 kvm 中,kvm 通知 QEMU,vCPU 線程會主動進(jìn)行 sleep 降頻。
下面我們再自底向上進(jìn)行分析。
#AC exception 被包含在 NMI exit reason 里面。
在 VMX non-root mode 下,CPU 檢測到 bus lock 后會產(chǎn)生 VM exit,reason 為 74。
-> vmx_handle_exit
-> __vmx_handle_exit
-> kvm_vmx_exit_handlers
-> handle_exception_nmi // split lock
-> handle_bus_lock_vmexit // bus lock
static int handle_exception_nmi(struct kvm_vcpu *vcpu)
{
switch (ex_no) {
case AC_VECTOR:
if (vmx_guest_inject_ac(vcpu)) {
kvm_queue_exception_e(vcpu, AC_VECTOR, error_code);
return 1;
}
/*
* Handle split lock. Depending on detection mode this will
* either warn and disable split lock detection for this
* task or force SIGBUS on it.
*/
if (handle_guest_split_lock(kvm_rip_read(vcpu)))
return 1;
}
// Guest handle
bool vmx_guest_inject_ac(struct kvm_vcpu *vcpu)
{
if (!boot_cpu_has(X86_FEATURE_SPLIT_LOCK_DETECT))
return true;
return vmx_get_cpl(vcpu)==3 && kvm_read_cr0_bits(vcpu, X86_CR0_AM) &&
(kvm_get_rflags(vcpu) & X86_EFLAGS_AC);
}
// KVM handle
bool handle_guest_split_lock(unsigned long ip)
{
if (sld_state==sld_warn) {
split_lock_warn(ip);
return true;
}
pr_warn_once("#AC: %s/%d %s split_lock trap at address: 0x%lx\n",
current->comm, current->pid,
sld_state==sld_fatal ? "fatal" : "bogus", ip);
current->thread.error_code=0;
current->thread.trap_nr=X86_TRAP_AC;
force_sig_fault(SIGBUS, BUS_ADRALN, NULL);
return false;
}
Guest 內(nèi)部產(chǎn)生 split lock 操作時,由于是#AC exception,會 VM exit 出來。
這里首先要說明一下#AC exception 本身有兩種類型:
KVM 根據(jù) Host 和 Guest 狀態(tài)最終產(chǎn)生兩種行為:
#define EXIT_REASON_BUS_LOCK 74
static int (*kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu)={
[EXIT_REASON_BUS_LOCK] =handle_bus_lock_vmexit,
}
static int handle_bus_lock_vmexit(struct kvm_vcpu *vcpu)
{
/*
* Hardware may or may not set the BUS_LOCK_DETECTED flag on BUS_LOCK
* VM-Exits. Unconditionally set the flag here and leave the handling to
* vmx_handle_exit().
*/
to_vmx(vcpu)->exit_reason.bus_lock_detected=true;
return 1;
}
union vmx_exit_reason {
struct {
u32 basic : 16;
u32 reserved16 : 1;
u32 reserved17 : 1;
u32 reserved18 : 1;
u32 reserved19 : 1;
u32 reserved20 : 1;
u32 reserved21 : 1;
u32 reserved22 : 1;
u32 reserved23 : 1;
u32 reserved24 : 1;
u32 reserved25 : 1;
u32 bus_lock_detected : 1;
u32 enclave_mode : 1;
u32 smi_pending_mtf : 1;
u32 smi_from_vmx_root : 1;
u32 reserved30 : 1;
u32 failed_vmentry : 1;
};
u32 full;
};
VM exit 后,執(zhí)行根據(jù) reason 74 索引的函數(shù) handle_bus_lock_vmexit,其中僅僅是把 exit_reason 的 bus_lock_detected 置位。
static int vmx_handle_exit(struct kvm_vcpu *vcpu, fastpath_t exit_fastpath)
{
int ret=__vmx_handle_exit(vcpu, exit_fastpath);
/*
* Exit to user space when bus lock detected to inform that there is
* a bus lock in guest.
*/
if (to_vmx(vcpu)->exit_reason.bus_lock_detected) {
if (ret > 0)
vcpu->run->exit_reason=KVM_EXIT_X86_BUS_LOCK;
vcpu->run->flags |=KVM_RUN_X86_BUS_LOCK;
return 0;
}
return ret;
}
執(zhí)行完__vmx_handle_exit 后,檢測到 bus_lock_detected 置位后會設(shè)置返回給用戶態(tài)的 exit_reason 和 flags。
以 v6.2.0 版本為例進(jìn)行分析:
-> kvm_cpu_exec
-> kvm_vcpu_ioctl
-> kvm_arch_post_run
-> kvm_arch_handle_exit
int kvm_arch_handle_exit(CPUState *cs, struct kvm_run *run)
{
switch (run->exit_reason) {
case KVM_EXIT_X86_BUS_LOCK:
/* already handled in kvm_arch_post_run */
ret=0;
break;
}
}
MemTxAttrs kvm_arch_post_run(CPUState *cpu, struct kvm_run *run) {
if (run->flags & KVM_RUN_X86_BUS_LOCK) {
kvm_rate_limit_on_bus_lock();
}
}
static void kvm_rate_limit_on_bus_lock(void)
{
uint64_t delay_ns=ratelimit_calculate_delay(&bus_lock_ratelimit_ctrl, 1);
if (delay_ns) {
g_usleep(delay_ns / SCALE_US);
}
}
QEMU 通過 KVM 返回值了解到 Guest 中有 bus lock 產(chǎn)生,進(jìn)入 kvm_rate_limit_on_bus_lock 中,也是通過 sleep 實(shí)現(xiàn) ratelimit,達(dá)到降低 Guest 產(chǎn)生 bus lock 頻率的效果。
Host 上的 ratelimit 是通過 split_lock_detect 啟動參數(shù)來控制的,那么 Guest 呢?
int kvm_arch_init(MachineState *ms, KVMState *s) {
if (object_dynamic_cast(OBJECT(ms), TYPE_X86_MACHINE)) {
X86MachineState *x86ms=X86_MACHINE(ms);
if (x86ms->bus_lock_ratelimit > 0) {
ret=kvm_check_extension(s, KVM_CAP_X86_BUS_LOCK_EXIT);
if (!(ret & KVM_BUS_LOCK_DETECTION_EXIT)) {
error_report("kvm: bus lock detection unsupported");
return -ENOTSUP;
}
ret=kvm_vm_enable_cap(s, KVM_CAP_X86_BUS_LOCK_EXIT, 0,
KVM_BUS_LOCK_DETECTION_EXIT);
if (ret < 0) {
error_report("kvm: Failed to enable bus lock detection cap: %s",
strerror(-ret));
return ret;
}
ratelimit_init(&bus_lock_ratelimit_ctrl);
ratelimit_set_speed(&bus_lock_ratelimit_ctrl,
x86ms->bus_lock_ratelimit, BUS_LOCK_SLICE_TIME);
}
}
}
static void x86_machine_get_bus_lock_ratelimit(Object *obj, Visitor *v,
const char *name, void *opaque, Error **errp)
{
X86MachineState *x86ms=X86_MACHINE(obj);
uint64_t bus_lock_ratelimit=x86ms->bus_lock_ratelimit;
visit_type_uint64(v, name, &bus_lock_ratelimit, errp);
}
static void x86_machine_class_init(ObjectClass *oc, void *data)
{
object_class_property_add(oc, X86_MACHINE_BUS_LOCK_RATELIMIT, "uint64_t",
x86_machine_get_bus_lock_ratelimit,
x86_machine_set_bus_lock_ratelimit, NULL, NULL);
}
#define X86_MACHINE_BUS_LOCK_RATELIMIT "bus-lock-ratelimit"
QEMU 對 Guest bus lock 的 ratelimit 從啟動參數(shù) bus-lock-ratelimit 中獲取。
Libvirt 支持 QEMU 的 bus-lock-ratelimit 啟動參數(shù)目前還沒 upsteam:
https://listman.redhat.com/archives/libvir-list/2021-December/225755.html
由于 X86 硬件的特性,支持跨 cache line 的原子語義,實(shí)現(xiàn)上需要用 split lock 維持原子性,但這卻需要以整個系統(tǒng)的訪存性能為代價。開發(fā)者逐漸意識到這個 feature 害人不淺,盡量不去使用,比如內(nèi)核的開發(fā)者保證自己不會產(chǎn)生 split lock,甚至不惜內(nèi)核 panic。而用戶態(tài)程序則會產(chǎn)生警告,然后降低程序的執(zhí)行頻率或者由內(nèi)核進(jìn)行 kill。虛擬化環(huán)境由于軟件棧較多,會盡量由 Host 側(cè) KVM 與 QEMU 處理,進(jìn)行告警甚至 kill 虛擬機(jī),或通知 QEMU 進(jìn)行降頻。