Bài 2

Ai xoá một khoá đã hết hạn

Một khoá hết hạn lúc 10:00:00 không biến mất khỏi bộ nhớ lúc 10:00:00. Nó biến mất khi có ai đó chạm vào nó, hoặc khi vòng quét nền tình cờ lấy đúng nó vào mẫu. Trong khoảng giữa hai thời điểm đó, khoá vẫn chiếm bộ nhớ và vẫn được DBSIZE đếm, dù mọi lệnh đọc đều trả về như không có gì.

Hai cơ chế xoá

Cả hai đều tăng expired_keys trong INFO stats, nên con số đó là tổng chung và không tách được ai xoá. Cách tách là tắt hẳn vòng quét chủ động.

Tắt vòng quét để tách hai cơ chế

DEBUG SET-ACTIVE-EXPIRE 0 tắt vòng quét. Từ Redis 7, DEBUG bị khoá theo mặc định, nên lần đầu chạy nó trả về:

ERR DEBUG command not allowed. If the enable-debug-command option is set
to "local", you can run it from a local connection, otherwise you need to
set this option in the configuration file, and then restart the server.

Đây là chỗ dễ đo sai nhất trong cả bài. Lệnh DEBUG thất bại mà bộ đo vẫn chạy tiếp, nên bảng kết quả đầu tiên trông như thể vòng quét đã tắt trong khi nó vẫn đang chạy — và mọi con số trong bảng đó đều vô nghĩa. Container phải khởi động lại với tuỳ chọn:

docker run -d --name rdlab -p 8088:6379 redis:7-alpine \
  redis-server --enable-debug-command local

docker exec rdlab redis-cli DEBUG SET-ACTIVE-EXPIRE 0
# OK

Với vòng quét đã tắt, dựng 300 khoá TTL 200 ms bằng một lệnh EVAL duy nhất (để chúng hết hạn cùng một thời điểm) cộng một khoá không hạn, rồi chờ một giây:

DBSIZE        → 301
KEYS *        → 1 khoá
expired_keys  → 0
Ba con số này không mâu thuẫn DBSIZE đếm số phần tử trong bảng khoá, không kiểm hạn từng khoá — nên nó trả về 301. KEYS kiểm hạn khi lọc kết quả, nên nó chỉ trả về 1. Còn expired_keys là 0 vì chưa có khoá nào bị xoá. Ba lệnh, ba câu trả lời khác nhau, tất cả đều đúng.

Chín lệnh, đo riêng từng lệnh

Mỗi hàng dưới đây là một lần dựng lại trạng thái từ đầu — 300 khoá đã hết hạn 800 ms trước, cộng một khoá còn sống, vòng quét chủ động tắt — rồi chạy đúng một lệnh:

LệnhTrả vềDBSIZE sauexpired_keys sauKhoá bị xoá
DBSIZE30130100
KEYS *130100
SCAN 0 COUNT 1012911010
SCAN 0 COUNT 100011300300
GET k2(nil)30011
MGET k5 k6 k73 × (nil)29833
DEL k10030011
SET k9 moiOK30111
RANDOMKEYsong2287373
KEYS lọc mà không xoá, SCAN thì xoá Hai lệnh cùng trả về một khoá, nhưng KEYS * để lại nguyên 300 khoá chết còn SCAN 0 COUNT 1000 xoá sạch cả 300. Và SCAN 0 COUNT 10 xoá đúng 10 — bằng số khoá nó đi qua. Nếu bạn đang dùng SCAN để dọn keyspace thì tác dụng dọn dẹp phần lớn đến từ chính việc quét, không phải từ lệnh DEL bạn gọi sau đó.
Hai hàng nữa đáng đọc kỹ DEL k10 trả về 0, không phải 1 — với Redis thì khoá đó đã không còn tồn tại, dù nó vẫn nằm trong bảng và vẫn bị xoá bởi chính lệnh này. Còn RANDOMKEY xoá tới 73 khoá trong một lệnh, vì nó rút khoá ngẫu nhiên và rút lại cho tới khi gặp một khoá còn sống; con số 73 là ngẫu nhiên nên mỗi lần chạy sẽ khác.

Vòng quét chủ động nhanh cỡ nào

Bật lại vòng quét, dựng 20 000 khoá TTL 400 ms bằng một lệnh EVAL, rồi đọc DBSIZE mỗi 100 ms kể từ lúc chúng hết hạn, không client nào chạm vào khoá:

Thời điểm sau khi hết hạnDBSIZEexpired_keys
0 ms36619 634
100 ms4219 958
200 ms020 000

20 000 khoá hết hạn cùng một thời điểm bị xoá sạch trong khoảng 200 ms, không cần ai đọc chúng. Hàng đầu tiên cũng đáng chú ý: DBSIZE đọc được 366 trong khi expired_keys đã là 19 634 — hai lệnh chạy cách nhau vài milisecond, và trong khoảng đó vòng quét đã xoá gần hết. Vòng quét không chậm; điều làm người ta tưởng nó chậm là những khoảnh khắc như hàng 0 ms này.

Vì vòng quét lấy mẫu ngẫu nhiên, thời gian dọn dẹp phụ thuộc vào tỷ lệ khoá hết hạn trong tập khoá có hạn. Khi tỷ lệ đó thấp — vài khoá hết hạn trong một triệu khoá có TTL dài — thì một khoá lẻ có thể nằm lại trong bộ nhớ khá lâu, và DBSIZE sẽ tiếp tục đếm nó.

Phòng thí nghiệm

Đặt số khoá sống, số khoá đã hết hạn, rồi chọn một lệnh. Vòng quét chủ động coi như đang tắt, nên chỉ lệnh bạn chạy mới xoá được khoá. Thuật toán đã đối chiếu với tám hàng đo được phía trên; hàng RANDOMKEY không có trong lab vì nó ngẫu nhiên.

Trạng thái keyspace

Lệnh duy nhất bạn chạy

Các lệnh đã đo:

Sau khi chạy lệnh

TTL thuộc về khoá, không thuộc về giá trị

Câu hỏi hay gặp thứ hai về hạn: lệnh nào làm mất TTL. Bảng dưới đây đo bằng cách đặt SET k 1 EX 100, chạy đúng một lệnh, rồi đọc TTL k:

LệnhTTL sau đóNghĩa là
SET k 2-1tạo lại khoá, hạn mất
SET k 2 KEEPTTL100giữ hạn cũ
SET k 2 EX 5050đặt hạn mới
APPEND k x100sửa nội dung tại chỗ
INCR k100sửa nội dung tại chỗ
SETRANGE k 0 9100sửa nội dung tại chỗ
GETSET k 9-1tương đương SET, hạn mất
GETDEL k-2khoá không còn
GETEX k100không tham số thì không đổi gì
GETEX k PERSIST-1bỏ hạn
PERSIST k-1bỏ hạn
COPY k k2100bản copy mang theo hạn
RENAME k k2100hạn đi theo khoá
RENAME k2 k (k2 không hạn)-1đè lên khoá có hạn, hạn cũ mất
RPUSH, HSET, SADD vào khoá có hạn100sửa nội dung tại chỗ
Một câu để nhớ cả bảng Lệnh nào thay hẳn khoá thì hạn mất; lệnh nào sửa nội dung bên trong khoá thì hạn còn. SET thuộc nhóm thứ nhất, đó là lý do KEEPTTL tồn tại. Chỗ thường bị bỏ qua là RENAME: hạn đi theo khoá nguồn, và nếu khoá đích đang có hạn thì hạn đó bị ghi đè cùng với dữ liệu.

TTL còn lại sau khi chạy lệnh

Khoá bắt đầu với SET k 1 EX 100.

Tự kiểm tra

Dashboard hiển thị DBSIZE = 5 triệu nhưng KEYS pattern:* gần như không trả về gì. Chuyện gì đang xảy ra? Phần lớn khoá đã hết hạn nhưng chưa bị xoá. DBSIZE đếm cả chúng, KEYS thì lọc bỏ. Muốn biết con số thật thì đọc expired_keys trong INFO stats và so với tốc độ ghi, hoặc chạy SCAN qua toàn bộ keyspace — chính việc quét sẽ dọn chúng đi.
Vì sao DEL trên một khoá đã hết hạn lại trả về 0? Vì trước khi xoá, DEL tra khoá và thấy nó đã quá hạn, nên với Redis khoá đó đã không tồn tại. Số 0 là số khoá đang tồn tại mà lệnh xoá được. Khoá vẫn bị dọn khỏi bảng trong cùng lệnh đó, và expired_keys tăng 1.
Bạn dùng SET để gia hạn một session bằng cách ghi lại giá trị. Sai ở đâu? SET không có EX hay KEEPTTL sẽ xoá hạn, và session đó trở thành vĩnh viễn. Cách đúng là SET … EX <giây> nếu muốn gia hạn, hoặc SET … KEEPTTL nếu chỉ muốn đổi giá trị mà giữ hạn cũ.
Một triệu khoá có TTL 30 ngày, trong đó mười khoá vừa hết hạn. Bao lâu chúng rời khỏi bộ nhớ? Không đoán được từ số đo trong bài. Vòng quét lấy mẫu ngẫu nhiên trong tập khoá có hạn, nên với tỷ lệ quá hạn cực thấp như vậy, xác suất mỗi nhịp bắt được một trong mười khoá đó là nhỏ. Số đo 200 ms cho 20 000 khoá ở trên ứng với trường hợp gần như toàn bộ tập khoá có hạn đều đã quá hạn — đúng chiều ngược lại.