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á
- Xoá lười. Bất kỳ lệnh nào tra khoá đều kiểm hạn trước. Khoá hết hạn thì bị xoá ngay tại đó, và lệnh trả về như thể khoá không tồn tại. Cơ chế này chỉ xoá đúng những khoá mà lệnh chạm tới.
-
Vòng quét chủ động. Mỗi nhịp
serverCron, Redis lấy một mẫu ngẫu nhiên trong tập khoá có hạn, xoá những khoá đã quá hạn, và nếu tỷ lệ quá hạn trong mẫu còn cao thì lặp thêm. Cơ chế này không cần client nào tham gia.
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
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ệnh | Trả về | DBSIZE sau | expired_keys sau | Khoá bị xoá |
|---|---|---|---|---|
DBSIZE | 301 | 301 | 0 | 0 |
KEYS * | 1 | 301 | 0 | 0 |
SCAN 0 COUNT 10 | 1 | 291 | 10 | 10 |
SCAN 0 COUNT 1000 | 1 | 1 | 300 | 300 |
GET k2 | (nil) | 300 | 1 | 1 |
MGET k5 k6 k7 | 3 × (nil) | 298 | 3 | 3 |
DEL k10 | 0 | 300 | 1 | 1 |
SET k9 moi | OK | 301 | 1 | 1 |
RANDOMKEY | song | 228 | 73 | 73 |
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 đó.
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ạn | DBSIZE | expired_keys |
|---|---|---|
| 0 ms | 366 | 19 634 |
| 100 ms | 42 | 19 958 |
| 200 ms | 0 | 20 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ệnh | TTL sau đó | Nghĩa là |
|---|---|---|
SET k 2 | -1 | tạo lại khoá, hạn mất |
SET k 2 KEEPTTL | 100 | giữ hạn cũ |
SET k 2 EX 50 | 50 | đặt hạn mới |
APPEND k x | 100 | sửa nội dung tại chỗ |
INCR k | 100 | sửa nội dung tại chỗ |
SETRANGE k 0 9 | 100 | sửa nội dung tại chỗ |
GETSET k 9 | -1 | tương đương SET, hạn mất |
GETDEL k | -2 | khoá không còn |
GETEX k | 100 | không tham số thì không đổi gì |
GETEX k PERSIST | -1 | bỏ hạn |
PERSIST k | -1 | bỏ hạn |
COPY k k2 | 100 | bản copy mang theo hạn |
RENAME k k2 | 100 | hạ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ạn | 100 | sửa nội dung tại chỗ |
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ũ.