Thiết kế tên key Redis — quy tắc đầy đủ, số đo thật, và những chỗ hay hỏng
Bài toán: Redis không có bảng, không có schema, không có namespace. Thứ duy nhất
giúp người vận hành biết một key là gì, của ai, xoá được không, nằm ở node nào trong Cluster —
chính là tên key. Đặt tên sai thì không báo lỗi ngay. Nó nổ trễ: script xoá nhầm
dữ liệu, RAM tăng không rõ do đâu, chuyển sang Cluster thì Lua báo CROSSSLOT, deploy
xong một nửa server đọc cache sai format.
Trả lời ngắn (30 giây):
- Format cố định:
<domain>:<entity>:<id>[:<phần con>][:v<N>], chữ thường,:chỉ dùng làm dấu phân cách. Ví dụorder:9812:items,category:children:v2:15.- Mỗi đoạn phải "sạch": dùng id, không dùng chuỗi người dùng nhập. Buộc phải dùng chuỗi tự do (từ khoá tìm kiếm, URL) thì băm nó (sha1/xxh3), đừng "lọc bỏ ký tự lạ" — lọc bỏ làm hai chuỗi khác nhau thành một key.
- Tên key tốn RAM thật: đo được 52 byte/key với tên 7 ký tự, 95 byte/key với tên 47 ký tự. Một triệu key thì chênh ~43 MB. Ngắn vừa đủ đọc, không viết tắt đến mức không ai hiểu.
- Cluster: chỉ dùng hash tag
{…}khi cần lệnh nhiều key trên cùng một thực thể, và tag phải là id có nhiều giá trị ({order:9812}), không phải hằng số ({global}).- Version nằm trong tên khi đổi format value, và mọi key cache phải có TTL, để key cũ tự biến mất.
Mọi output trong bài là chạy thật: Redis 8.8.0 (container tạm, một bản thường và
một bản bật cluster-enabled), Laravel 12.64.0 + phpredis 6.3.0 chạy code của project
laravel-livewire nối vào Redis tạm đó. Chỗ nào chưa verify có ghi rõ. Phần "blog nói gì" có dẫn nguồn
ở cuối bài, kèm nhận xét chỗ nào blog nói chưa đúng.
Thuật ngữ dùng trong bài
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| keyspace | Toàn bộ key trong một database của Redis. Bên dưới là một bảng băm (dict) lớn. |
| binary-safe | Tên key là dãy byte bất kỳ. Redis không hiểu chữ hoa/thường, Unicode hay dấu cách — so từng byte. |
| namespace (quy ước) | Redis không có namespace thật. "Namespace" chỉ là phần đầu tên key giống nhau, vd order:. |
| separator | Ký tự phân cách các đoạn trong tên key. Quy ước chung là dấu :. |
| cardinality | Số giá trị khác nhau một đoạn có thể nhận. user_id cardinality cao, status cardinality thấp. |
| hash slot / hash tag | Cluster chia key vào 16384 slot. Nếu tên có {…} thì chỉ phần trong ngoặc được băm — gọi là hash tag. |
| CROSSSLOT | Lỗi Cluster trả về khi một lệnh (hoặc Lua script) đụng các key thuộc slot khác nhau. |
| hot slot / hot key | Một slot hoặc một key nhận phần lớn traffic, làm một node quá tải dù cluster còn nhiều node rảnh. |
| glob pattern | Mẫu dùng trong SCAN MATCH, KEYS, ACL: *, ?, [abc]. |
| ACL key pattern | Luật ~pattern trong ACL: user Redis chỉ được đụng key khớp mẫu. |
| orphan key | Key "mồ côi": không còn code nào đọc/xoá nó, nhưng vẫn chiếm RAM. |
1. Nó là gì / chạy thế nào
1.1. Redis tìm key thế nào — và vì sao tên là thứ duy nhất bạn có
Mỗi database của Redis là một bảng băm. GET order:1 băm chuỗi byte order:1,
nhảy vào ô tương ứng, so sánh từng byte. Không có index phụ, không có "thư mục", không có cột
type. Hệ quả:
- Tìm một key theo đúng tên: O(1). Tìm "mọi key bắt đầu bằng
order:1:": phải quét cả keyspace — prefix không được index (số đo ở mục 1.6). - So từng byte:
user:Alice,user:alice,user:alice␠(có dấu cách cuối) là 3 key khác nhau.caféviết dạng NFC và NFD (chữ é một ký tự hoặc e + dấu) cũng là 2 key.
SET user:Alice 1 ; SET user:alice 2 ; SET "user:alice " 3
SET tag:café (NFC) ; SET tag:café (NFD)
DBSIZE → 5 key (cộng các key khác trong test)
SCAN ... | cat -A
tag:cafM-CM-)$ ← é = 2 byte C3 A9
tag:cafeM-LM-^A$ ← e + dấu = 1 + 2 byte CC 81
Nghĩa là mọi đoạn lấy từ dữ liệu người dùng phải được chuẩn hoá (chữ thường, trim, Unicode NFC) trước khi ghép vào tên key — hoặc tốt hơn, không dùng chúng mà dùng id.
1.2. Giải phẫu một tên key
flowchart LR P["prefix app
(framework tự thêm)
laravel-database-"] --> D["domain
catalog / order / forum"] D --> E["entity / mục đích
category:children"] E --> V["version
v2"] V --> I["id
15"] I --> S["phần con
items / lock / count"]
| Đoạn | Để làm gì | Ví dụ | Bắt buộc? |
|---|---|---|---|
| prefix app | Tách key của các app dùng chung một Redis; làm ACL theo app | laravel-database-, shop: | Chỉ khi nhiều app dùng chung. Framework thường tự thêm |
| domain | Biết key thuộc module/team nào, ai chịu trách nhiệm | catalog, forum | Nên có khi codebase có nhiều module |
| entity / mục đích | Biết value là gì | category:children, home:popular | Có |
| version | Đổi format value mà không đọc nhầm dữ liệu cũ | v2 | Có với key cache chứa object/JSON |
| id | Phân biệt từng thực thể | 15, {order:9812} | Có (trừ key toàn cục) |
| phần con | Các key khác nhau quanh cùng một thực thể | items, lock | Tuỳ |
Ví dụ từ code thật của project laravel-livewire (app/Services). Lưu ý {$…} ở đây là chèn biến của PHP, không phải hash tag (mục 2.9):
category:subtree:{$category->id}
category:children:v2:{$parentId} // v2: nav view-model đổi slug → id (issue #54)
home:popular:{$limit}
product:variants:{$product->id}
price:v1:{$product->id}:{$product->pricing_version} // version theo dữ liệu, xem mục 2.5
cat:{$categoryId}:price:{$min}-{$max} // CategoryBrowseCatalog: band giá lấy từ config
Chỗ tốt: format đều, có version, tên key được dựng trong một method cacheKey() riêng.
Chỗ cần để ý: home:popular:{$limit} — nếu $limit đến từ request thì số key không
bị chặn (mục 2.7). Trong project, $limit là hằng số gọi từ code nên ổn.
1.3. Tên key tốn RAM thật — số đo
Mỗi key phải lưu nguyên chuỗi tên (trong object key/entry của dict). Test: 100.000 key
SET <tên> 1, đo used_memory trước/sau, chia cho số key.
| Tên key (ví dụ) | Độ dài tên | byte / key | Quy ra 1 triệu key |
|---|---|---|---|
u:12345 | ~7 | 52 | ~50 MB |
user:session:12345 | ~18 | 60 | ~57 MB |
order:<uuid 36 ký tự> | 42 | 79 | ~75 MB |
myshop-production-cache-user-session-data:12345 | ~47 | 95 | ~91 MB |
| tên ~91 ký tự | ~91 | 132 | ~126 MB |
MEMORY USAGE user:1 → 32
MEMORY USAGE myshop-production-cache-user-session-data:1 → 67
Đọc số này thế nào:
- Với vài chục nghìn key, chênh lệch là vài MB — không đáng đổi
sự dễ đọc lấy nó. Đừng viết tắt
u:s:1. - Với hàng chục triệu key nhỏ (value chỉ vài byte), tên key chiếm phần lớn RAM. Lúc đó cách tiết kiệm thật không phải là rút ngắn tên, mà là gom nhiều object vào một hash (bucket) — xem Redis — những thứ cần biết, mục 1.2: 55 byte/SV xuống còn vài byte/SV.
- Prefix framework (
laravel-database-= 17 byte) bị cộng vào mọi key. Một triệu key → ~17 MB chỉ cho prefix. ĐặtREDIS_PREFIXngắn nếu số key lớn.
1.4. Tên key quyết định node trong Redis Cluster
Cluster tính slot = CRC16(tên) mod 16384. Nếu tên có { và sau đó có
}, và giữa hai ngoặc có ít nhất 1 ký tự, thì chỉ phần trong ngoặc
đầu tiên được băm (theo HASH_SLOT trong cluster spec).
flowchart TD
K["Tên key"] --> Q1{"Có dấu { ?"}
Q1 -- "không" --> H1["Băm cả tên"]
Q1 -- "có" --> Q2{"Có } phía sau
và ở giữa không rỗng?"}
Q2 -- "không" --> H1
Q2 -- "có" --> H2["Chỉ băm phần giữa
{ đầu tiên và } đầu tiên sau nó"]
Chạy CLUSTER KEYSLOT trên Redis 8.8 bật cluster mode:
| Tên key | Slot | Ghi chú |
|---|---|---|
user:123:profile | 8490 | Không tag → băm cả tên |
user:{123}:profile | 5970 | Cùng tag 123 → cùng slot |
user:{123}:cart | 5970 | |
{user:123}:cart | 12893 | Tag là user:123, khác 123 → khác slot |
user:{}:x | 14781 | Ngoặc rỗng → băm cả tên |
user:{123 | 8567 | Thiếu } → băm cả tên |
{a}{b} và {a}x | 15495 | Chỉ lấy cặp ngoặc đầu |
user:{789:456}:meta và 789:456 | 14151 | Tag có dấu : vẫn đúng — lấy tới } đầu tiên |
laravel-database-{order:9}:items / :lock | 14638 | Prefix ở ngoài ngoặc không ảnh hưởng |
queues:default / queues:default:reserved | 11255 / 5202 | Khác slot → Lua của queue lỗi CROSSSLOT |
queues:{default} / queues:{default}:reserved | 5718 / 5718 | Vì vậy docs Laravel bảo đặt tên queue là {default} khi chạy Cluster |
Thêm: SELECT 1 trên node cluster → ERR SELECT is not allowed in cluster mode.
Tách dữ liệu bằng số DB (REDIS_DB=8, REDIS_CACHE_DB=9) sẽ không chuyển
sang Cluster được — chỉ tách bằng tên key mới đi theo được.
1.5. Tên key mà Laravel thật sự ghi vào Redis
Đây là phần hay bị bỏ qua nhất: bạn viết Cache::put('home:popular:8') nhưng tên thật
trong Redis không phải home:popular:8. Test với .env của project
(REDIS_DB=8, REDIS_CACHE_DB=9, CACHE_PREFIX=clicon_local), code:
Cache::store('redis')->put('home:popular:8', 'x', 60);
Redis::set('forum:liked:{7}:3', 1);
Cache::store('redis')->lock('order:9:pay', 10)->get();
RateLimiter::hit('storefront|1.2.3.4', 60);
Cache::store('redis')->tags(['product:5'])->put('card', 'x', 60);
Queue::connection('redis')->push('App\\Jobs\\Nope');
Queue::connection('redis')->later(60, 'App\\Jobs\\Nope');
Output redis-cli --scan từng DB:
== db8 (connection default)
laravel-database-clicon_localorder:9:pay ← lock nằm ở DB 8, không phải DB cache
laravel-database-forum:liked:{7}:3
laravel-database-queues:default
laravel-database-queues:default:delayed
laravel-database-queues:default:notify
== db9 (connection cache)
laravel-database-clicon_localhome:popular:8
laravel-database-clicon_localstorefront|1.2.3.4
laravel-database-clicon_localstorefront|1.2.3.4:timer
laravel-database-clicon_localece4b669a7fbe4664e188bb670019a45566ffa83:card
laravel-database-clicon_localtag:product:5:entries → type=zset ttl=-1
flowchart LR
A["Cache::put('home:popular:8')"] --> B["RedisStore thêm cache.prefix
clicon_local (không tự thêm dấu phân cách)"]
B --> C["phpredis OPT_PREFIX thêm
database.redis.options.prefix
laravel-database-"]
C --> D["Tên thật trong Redis DB 9
laravel-database-clicon_localhome:popular:8"]
Năm điều rút ra (đều đã thấy trong output):
- Hai lớp prefix chồng nhau: prefix của connection Redis
(
laravel-database-) + prefix của cache (clicon_local). CodeRedisStore::setPrefix()ở Laravel 12.64 gán thẳng, không tự thêm dấu phân cách..envghiCACHE_PREFIX=clicon_local(thiếu-hoặc:cuối) → tên dính liềnclicon_localhome. Nguy hiểm: prefixapp+ keyle:1trùng với prefixapple:... khi SCAN/ACL theo mẫu. Config mặc định của Laravel có dấu cuối (-cache-), chỉ hỏng khi ghi đè bằng env. - Lock không nằm cùng DB với cache:
lock_connectionmặc định làdefault(DB 8).cache:clearxoá DB 9 nhưng lock vẫn còn — đúng ý đồ, nhưng người đi tìm key lock trong DB cache sẽ không thấy. - Cache tags làm tên key không đọc được: key thật là
sha1(tên tag):card. Không SCAN theo tag được. Tag còn tạo ZSETtag:product:5:entrieskhông có TTL — phải chạyphp artisan cache:prune-stale-tagstheo lịch, nếu không nó phình dần. Redis::keys()trả tên đã có prefix, truyền lại vàoRedis::del()thì bị thêm prefix lần nữa:Redis::del('laravel-database-forum:liked:{7}:3')→ 0 (không xoá gì, không báo lỗi). Phải cắt prefix trước khi dùng lại.- Có đường đi vòng qua prefix:
rawCommand('SET','raw:key',…)ghi raraw:keykhông prefix. Trong Lua, key truyền quaKEYSđược prefix (laravel-database-outer:7), nhưng key tự ghép trong script ('inner:' .. ARGV[1]) thì không (inner:7). Key đó nằm ngoài "namespace" của app: ACL theo prefix chặn nó, SCAN theo prefix bỏ sót nó, và trên Cluster nó là lỗi vì không khai báo trongKEYS.
1.6. Tên key là "công cụ vận hành": SCAN, ACL, đo RAM
SCAN theo prefix không nhanh hơn — vẫn phải đi qua mọi key. Test: 1.000.100 key,
chỉ 100 key khớp tenant:42:*:
SCAN cursor MATCH tenant:42:* COUNT 1000 → 1000 lần gọi mới quét hết, tìm được 100 key
SCAN 0 MATCH tenant:42:* COUNT 1000000 → 100 key, SLOWLOG: 553677 µs (≈ 553 ms chặn server)
Nên prefix giúp lọc, không giúp tìm nhanh. Nếu thường xuyên phải "lấy mọi key của
tenant 42" thì lưu danh sách đó ra một SET riêng (tenant:42:keys), hoặc gom vào một hash.
ACL theo prefix — lý do mạnh nhất để có prefix rõ ràng khi nhiều app/tenant dùng chung:
ACL SETUSER app42 on >pw42 ~tenant:42:* +@read +@write
GET tenant:42:k1 (user app42) → 1
GET tenant:43:k1 (user app42) → NOPERM No permissions to access a key
GET noise:1 (user app42) → NOPERM No permissions to access a key
Đo RAM theo nhóm: khi Redis đầy, câu hỏi đầu tiên là "nhóm key nào chiếm RAM?".
Chỉ trả lời được nếu đoạn đầu tên key nói lên chủ sở hữu. Cách làm: SCAN có COUNT nhỏ trên replica,
lấy mẫu MEMORY USAGE, cộng dồn theo 2 đoạn đầu của tên. redis-cli --bigkeys /
--memkeys cho key lớn nhất theo type (chưa chạy thử trong bài này).
2. Bộ quy tắc đặt tên — mỗi quy tắc kèm bẫy
2.1. Một format duy nhất, chữ thường, : chỉ làm dấu phân cách
<domain>:<entity>[:v<N>]:<id>[:<phần con>]
order:9812 order:9812:items order:9812:lock
category:children:v2:15 rl:login:ip:1.2.3.4
forum:liked:{7}:3 (user 7, course 3 — hash tag theo user)
Tên nhiều từ trong một đoạn dùng - hoặc _ (best-sellers),
chọn một và giữ nguyên cả codebase.
Bẫy:
- Trộn
.,_,:làm dấu phân cách trong cùng hệ thống → pattern xoá/ACL/giám sát viết sai mà không ai biết. - Công cụ GUI (RedisInsight…) chia cây thư mục theo
:. Dùng dấu khác thì mất cây.
2.2. Không đoạn nào được chứa dấu phân cách — dùng id, hoặc băm
Ghép chuỗi có dấu : vào tên thì hai bộ giá trị khác nhau cho ra cùng một key:
# route cache: (from="a:b", to="c") và (from="a", to="b:c")
SET rt:a:b:c "owner=(a:b , c)" → OK
SET rt:a:b:c "owner=(a , b:c)" → OK
GET rt:a:b:c → "owner=(a , b:c)" ← ghi đè im lặng, người 1 đọc nhầm dữ liệu người 2
Cách đúng, theo thứ tự ưu tiên:
- Dùng id số/uuid thay vì slug, email, tên.
- Chuỗi tự do (từ khoá tìm kiếm, bộ lọc, URL) → chuẩn hoá rồi băm:
search:v1:{sha1(json_encode($normalizedFilters))}. Hex sha1 = 40 ký tự, md5 = 32; cần ngắn hơn thì dùnghash('xxh128', …)(32 hex) — trong PHP 8.1+ có sẵn.
Bẫy:
- Lời khuyên "lọc bỏ ký tự không phải chữ/số" trên nhiều blog là sai cho việc tạo key:
re.sub(r'[^a-zA-Z0-9_-]', '', v)biến"a:b"và"ab"thành cùngab→ hai dữ liệu khác nhau dùng chung cache. Lọc chỉ an toàn nếu bạn từ chối input xấu, không phải sửa nó. - Băm mà quên chuẩn hoá trước:
{"a":1,"b":2}và{"b":2,"a":1}cho 2 hash khác → cache hit rate thấp. Sắp xếp key mảng (ksort) trước khijson_encode. - Key có ký tự glob (
[,*,?) sẽ làm pattern hiểu sai:SCAN MATCH search:[sale]trảsearch:s,search:a(hiểu là "một trong các ký tự s,a,l,e"), không trảsearch:[sale]. Phải escapesearch:\[sale\]. Băm thì không bao giờ gặp. - Dấu
{}trong dữ liệu người dùng lọt vào tên key sẽ vô tình thành hash tag trên Cluster → key dồn về một slot người dùng chọn được.
2.3. Prefix phải kết thúc bằng dấu phân cách — cả khi đặt tên lẫn khi SCAN/xoá
Có: order:1 order:1:items order:12 order:12:items order:100 order:1999 ...
SCAN MATCH order:1* → order:1 order:1:items order:12 order:12:items order:100
order:100:items order:1999 order:1999:items ← xoá nhầm 3 đơn khác
SCAN MATCH order:1:* → order:1:items ← đúng, nhưng thiếu chính order:1
Cách đúng: xoá theo danh sách tên cụ thể (order:1,
order:1:items, order:1:lock) do code sinh ra, không xoá theo pattern. Nếu
cần xoá theo pattern, đặt mọi key của thực thể dưới cùng một nhánh:
order:1:data, order:1:items → order:1:* bắt đủ.
Bẫy: CACHE_PREFIX/REDIS_PREFIX thiếu dấu cuối (mục 1.5) là
đúng lỗi này ở tầng framework.
2.4. Ngắn vừa đủ đọc
- Dưới ~1 triệu key: ưu tiên dễ đọc.
category:children:v2:15tốt hơnc:ch:2:15. - Hàng chục triệu key nhỏ: rút tên chỉ tiết kiệm vài chục %. Đổi cấu trúc (hash bucket) mới tiết kiệm hàng chục lần.
- Đoạn lặp lại ở mọi key (prefix app, tên domain dài) là chỗ rút ngắn đáng nhất.
Bẫy: viết tắt không có tài liệu → 6 tháng sau không ai biết ps: là
product-stock hay payment-session, không ai dám xoá → orphan key sống mãi.
2.5. Version nằm trong tên khi format value thay đổi
Có hai kiểu version, project laravel-livewire dùng cả hai:
| Kiểu | Ví dụ | Khi nào đổi | Tác dụng |
|---|---|---|---|
| Version schema (sửa tay trong code) | category:top-level:v2 | Đổi cấu trúc JSON/object lưu trong value | Code mới không đọc phải value format cũ |
| Version dữ liệu (lấy từ DB) | price:v1:{id}:{pricing_version} | Mỗi lần giá sản phẩm đổi, pricing_version tăng | Không cần xoá cache: key cũ tự hết hạn, key mới được nạp |
sequenceDiagram participant Old as Pod bản cũ participant New as Pod bản mới participant R as Redis Note over Old,New: Đang rolling deploy, 2 bản chạy song song Old->>R: GET category:top-level (format có slug) New->>R: GET category:top-level:v2 (format có id) R-->>New: miss, query DB, SET ...:v2 Note over R: Hai key sống song song, không ai đọc nhầm Note over R: Key cũ hết TTL thì tự biến mất
Bẫy:
- Key không có TTL + đổi version = orphan key vĩnh viễn. Version chỉ an toàn khi đi cùng TTL.
- Đổi version cho key nóng = cache của cả hệ thống trống cùng lúc → dồn query xuống DB (stampede). Deploy lúc ít traffic, hoặc warm trước.
- Rollback về bản cũ: bản cũ đọc key cũ — nếu key cũ còn sống thì nó có thể là dữ liệu đã lỗi thời (không ai cập nhật trong lúc bản mới chạy). Xem Cache khi rollback blue-green.
- Quên bump version khi đổi format → bản mới
unserializeobject cũ: lỗi lúc chạy hoặc tệ hơn, thiếu field im lặng.
2.6. Nhìn tên là biết loại dữ liệu: cache hay state
| Loại | Mất thì sao | TTL | Ví dụ tên |
|---|---|---|---|
| Cache | Nạp lại từ DB | Bắt buộc | home:popular:8 |
| Lock / idempotency / rate limit | Có thể chạy trùng | Bắt buộc, ngắn | order:9812:lock, rl:login:ip:… |
| State (queue, stream, counter chưa flush) | Mất dữ liệu | Không | queues:{default}, forum:like-events |
Bẫy: để cache và state chung một instance với maxmemory-policy allkeys-lru
thì eviction xoá cả queue. Tên key tách được loại dữ liệu để nhìn, nhưng eviction không đọc tên —
muốn an toàn thật thì tách instance (hoặc ít nhất tách connection/DB khi chưa lên Cluster).
2.7. Số key phải bị chặn: mọi tham số trong tên đến từ tập hữu hạn
CategoryBrowseCatalog trong project làm đúng: tên key có band giá, nhưng band
lấy từ config, và scope lọc giá tự do thì không cache. Comment
trong code: "so the key space stays bounded to real categories times configured bands".
Bẫy: products:page:{$request->page},
home:popular:{$request->limit}, search:{$q} → bot gửi
?page=1..10000000 là tạo 10 triệu key, đẩy RAM đầy và eviction xoá cache thật. Chặn giá trị
(min($limit, 50), whitelist) trước khi đưa vào tên key.
2.8. Tách môi trường bằng instance, không bằng prefix
Nhiều blog khuyên dev:user:1, staging:user:1, prod:user:1 trên
cùng Redis. Đây là lời khuyên dở cho production: chung CPU (một luồng chạy lệnh — một
KEYS ở dev làm prod đứng), chung maxmemory (dev đầy RAM làm prod bị evict),
FLUSHALL xoá cả hai, chung failover. Prefix môi trường chỉ chấp nhận được cho dev/staging dùng
chung với nhau.
Prefix app thì có lý khi nhiều service dùng chung một Redis — kèm ACL
~app:* cho từng service.
2.9. Hash tag: chỉ khi cần, và tag phải có nhiều giá trị
- Cần: Lua/
MULTI/MGET/SINTER/RENAMEđụng nhiều key của cùng một thực thể →cart:{u:42}:items,cart:{u:42}:total. - Tag = id thực thể (user, order, tenant nhỏ). Không dùng hằng số hay giá trị ít loại:
{global},{PENDING}/{DONE}dồn hàng triệu key vào 1–3 slot trên 16384 → một node gánh hết (ví dụ trong bài của Percona ở cuối). - Không cần: key đọc/ghi độc lập. Gắn tag "cho chắc" chỉ làm lệch phân bố.
Bẫy:
- Tag theo tenant lớn (
{tenant:1}chiếm 40% traffic) → hot slot. Không chia nhỏ được mà không đổi tên key — tức là migrate dữ liệu. - Đổi tên key để thêm tag khi đã lên production = đổi mọi chỗ đọc/ghi + chạy song song 2 tên. Nếu có khả năng lên Cluster, nghĩ về tag từ đầu cho các nhóm key đi cùng Lua.
- Queue Laravel trên Cluster: tên queue phải là
{default}(mục 1.4). - PHP nuốt mất dấu ngoặc: trong chuỗi
"…",{$id}là cú pháp chèn biến, không phải hash tag. Test:"cart:{$id}:items"→cart:42:items(không có tag);"cart:{{$id}}:items"→cart:{42}:items;"cart:{u:$id}:items"→cart:{u:42}:items. Nhìn code tưởng có tag, lên Cluster mới báo CROSSSLOT.
2.10. Dựng tên key ở một chỗ
Đủ dùng: một method private trong service sở hữu key, như project đang làm:
private static function cacheKey(Product $product): string
{
return "price:v1:{$product->id}:{$product->pricing_version}";
}
Chỗ đọc, chỗ ghi, chỗ xoá (listener khi model đổi) đều gọi method này → không lệch tên vì gõ nhầm. Không cần dựng "KeyRegistry"/"KeyBuilder" dùng chung cho cả app khi mỗi service chỉ có vài key — đó là abstraction không ai cần. Khi key được dùng ở nhiều service khác nhau thì mới đưa method ra một class chung.
2.11. Có bảng key trong tài liệu
| Tên key | Type | TTL | Ai ghi / ai xoá | Cỡ dự kiến | Mất thì sao |
|---|---|---|---|---|---|
price:v1:{id}:{pricing_version} | string | 1h | PricedVariants / hết TTL | ~1 KB × số SP xem | Nạp lại |
queues:{default} | list | — | Laravel queue | theo backlog | Mất job |
(Hai dòng trên là ví dụ minh hoạ cách ghi bảng.) Bảng này là thứ người trực đêm cần nhất khi Redis đầy RAM.
3. Lợi ích
- Không đụng key: hai module/hai app không ghi đè nhau im lặng.
- Vận hành được: biết nhóm nào chiếm RAM, key nào xoá được, key nào của team nào.
- Bảo mật: ACL
~prefix:*giới hạn mỗi service chỉ đụng key của mình (đã test ở mục 1.6). - Deploy an toàn: version trong tên cho phép 2 bản code chạy song song.
- Lên Cluster được: hash tag đặt đúng từ đầu thì Lua/MULTI không phải viết lại.
4. Tác hại / hạn chế
- Tốn RAM: tên dài + prefix cộng vào mọi key (52 → 95 byte/key trong số đo).
- Prefix không phải index: SCAN theo prefix vẫn quét toàn bộ (1000 lần SCAN cho 100 key).
- Prefix không phải cô lập tài nguyên: chung CPU, RAM, eviction, failover.
- Đổi tên key trên production rất đắt: không có
ALTER. Phải ghi/đọc 2 tên song song, rồi chờ TTL hoặc migrate. - Hash tag khoá cứng cách chia dữ liệu: chọn sai tag thì sửa = đổi tên key.
- Framework che mất tên thật: prefix chồng nhau, tag bị sha1,
rawCommand/Lua đi vòng prefix (mục 1.5).
5. Trade-off — chọn cái nào
| Lựa chọn | Phương án A | Phương án B | Tiêu chí chọn |
|---|---|---|---|
| Độ dài tên | Dài, dễ đọc | Ngắn, viết tắt | < vài triệu key → A. Hàng chục triệu key nhỏ → đổi sang hash bucket trước, rút tên sau |
| Mỗi object một key vs gom hash | progress:{sv} | progress:{sv/500} field sv | Cần TTL/eviction riêng từng object → A. RAM là vấn đề và object nhỏ → B (xem bài Redis cần biết) |
| Invalidate cache | Version trong tên | Xoá key khi dữ liệu đổi | Có version sẵn trong DB (pricing_version, updated_at) → A, không race. Key ít, sự kiện đổi rõ ràng → B |
| Tách app / môi trường | Prefix + ACL | Instance riêng | Khác môi trường (prod vs dev) → luôn B. Nhiều service cùng prod, cùng SLA → A chấp nhận được |
| Tách cache / queue | Số DB (SELECT) | Instance riêng | Chưa và sẽ không lên Cluster, cùng policy → A. Cần eviction policy khác nhau → B. Cluster → chỉ B |
| Hash tag | Có tag theo thực thể | Không tag | Có lệnh nhiều key cần atomic → A. Không có → B (phân bố đều hơn) |
| Tên chứa chuỗi tự do | Băm | Chuỗi gốc (đã escape) | Mặc định A. B chỉ khi cần đọc tên để debug và tập giá trị nhỏ, đã validate |
6. Khó khăn khi vận hành thực tế
- Không có ai "sở hữu" quy ước. Quy ước chỉ sống nếu có review. Thêm vào checklist PR: key mới có TTL không? tham số trong tên có bị chặn không? đã thêm vào bảng key chưa?
- Tìm thủ phạm RAM lúc 3h sáng:
KEYS *là cấm (chặn server). Chạy SCAN COUNT nhỏ trên replica, lấy mẫuMEMORY USAGE, gom theo 2 đoạn đầu tên. Nếu tên không có đoạn "chủ sở hữu" thì bước này vô dụng. - Tên thật khác tên trong code (prefix Laravel). Runbook phải ghi rõ prefix thật,
DB number, để người trực gõ đúng
redis-cli -n 9 --scan --pattern 'laravel-database-clicon_local…'. - Đổi tên key / đổi prefix (ví dụ đổi
APP_NAMElàm đổi prefix mặc định!) = toàn bộ cache miss cùng lúc + session/queue cũ "biến mất". Coi thay đổi prefix như một migration. - Monitoring theo nhóm key không có sẵn trong
INFO. Cần job định kỳ lấy mẫu, hoặc exporter hỗ trợ đếm theo pattern (vdredis_exportercó tuỳ chọn check key theo pattern — chưa verify trong bài này).
7. Case thực tế và cách xử lý
Các case dưới là tình huống minh hoạ dựng từ các bẫy đã test ở trên, không phải sự cố của một hệ thống cụ thể.
Case 1 — Script "xoá cache đơn hàng 1" làm khách của đơn 12, 100, 1999 thấy dữ liệu cũ/trống
Triệu chứng: sau khi support chạy lệnh dọn cache cho đơn 1, vài đơn khác phải nạp lại, một số trang báo lỗi vì key lock của đơn khác cũng bị xoá.
- Xem script:
SCAN MATCH order:1*+DEL. Pattern thiếu dấu phân cách (mục 2.3). - Chạy lại pattern ở chế độ chỉ in (không DEL) để đếm phạm vi đã xoá nhầm.
- Cache thì tự nạp lại. Key lock/idempotency bị xoá → kiểm tra có job nào chạy trùng trong khoảng đó.
- Sửa lâu dài: code có method xoá theo danh sách tên cụ thể của một đơn; cấm xoá theo pattern trong runbook.
Case 2 — Rolling deploy xong, 50% request lỗi 500 trong 15 phút rồi tự hết
Triệu chứng: lỗi Undefined array key "id" chỉ ở pod mới; hết đúng bằng TTL cache.
- Bản mới đổi cấu trúc value (slug → id) nhưng giữ tên
category:top-level. - Pod cũ ghi format cũ, pod mới đọc phải → lỗi. Pod mới ghi format mới, pod cũ đọc phải → lỗi.
- Chữa nhanh: bump version tên key (
:v2) và deploy lại; khôngcache:cleargiữa lúc 2 bản cùng chạy (pod cũ sẽ ghi lại format cũ ngay). - Phòng: checklist PR "đổi shape value → bump version tên key". Đây đúng là lý do của comment issue #54 trong project.
Case 3 — RAM Redis tăng đều 2%/ngày, không giảm dù traffic ổn định
- Lấy mẫu SCAN trên replica, gom theo prefix → thấy nhóm
search:*chiếm 60%, và…tag:*:entriesnhiều ZSET lớn. search:{$q}: không chặn cardinality và không có TTL → mỗi từ khoá bot gửi là một key vĩnh viễn. Sửa: băm + TTL + chỉ cache query phổ biến.- ZSET tag của Laravel không có TTL (
ttl=-1trong test) → thêm$schedule->command('cache:prune-stale-tags')->hourly(). - Dọn key cũ: SCAN +
UNLINKtheo lô nhỏ, có sleep giữa các lô, chạy giờ thấp điểm.
Case 4 — Chuyển sang Redis Cluster, queue và Lua báo CROSSSLOT
- Queue Laravel dùng Lua đụng
queues:defaultvàqueues:default:reserved— hai slot khác nhau (11255 vs 5202). - Đổi tên queue thành
{default}. Job còn trong queue cũ phải drain trước (worker chạy hết queue cũ trên Redis cũ), rồi mới chuyển. - Lua script tự viết: mọi key phải truyền qua
KEYSvà có cùng tag. Tìm key tự ghép trong script ('inner:' .. ARGV[1]) — vừa sai Cluster vừa không có prefix. SELECTkhông dùng được → bỏ tách bằngREDIS_CACHE_DB, tách bằng instance/prefix.
Case 5 — Dev chạy "dọn key cũ" bằng Redis::keys() + Redis::del(), không xoá được gì
Redis::keys('forum:*')trảlaravel-database-forum:…(đã có prefix).Redis::del($k)thêm prefix lần nữa → xoá key không tồn tại → trả 0, không lỗi. Test thật: 0.- Sửa: cắt prefix
Str::after($k, config('database.redis.options.prefix'))trước khi del; và thaykeys()bằng SCAN (keys()chặn server, trên Cluster còn là FatalError).
8. Câu hỏi phụ interviewer hay hỏi tiếp
- Vì sao dùng dấu
:? - Không có lý do kỹ thuật trong Redis — chỉ là quy ước được docs Redis và công cụ GUI dùng. Quan trọng là một dấu duy nhất và không xuất hiện trong dữ liệu.
- Key dài tối đa bao nhiêu?
- 512 MB theo docs, nhưng vô nghĩa. Tên dài tốn RAM mỗi key và tốn CPU băm/so sánh. Giữ dưới ~100 byte; chuỗi dài thì băm.
- Dùng số DB (
SELECT) hay prefix? - Prefix. DB number không có trong Cluster (
ERR SELECT is not allowed in cluster mode), không có ACL riêng theo DB, vẫn chung CPU/RAM. DB number chỉ tiện choFLUSHDBcache riêng khi còn một instance. - Hash tag đặt ở đâu trong tên?
- Vị trí không quan trọng, chỉ cặp ngoặc đầu tiên có nội dung được tính. Quy ước là bọc id thực thể:
cart:{u:42}:items. Đừng có hai cặp ngoặc — cặp thứ hai bị bỏ qua, người đọc dễ hiểu nhầm. - Khi nào nên dùng hash thay vì nhiều key?
- Nhiều object nhỏ, không cần TTL riêng từng object (hoặc Redis 7.4+ có
HEXPIRE), cần tiết kiệm RAM. Không dùng khi object cần evict riêng hoặc hash sẽ thành big key. - Có nên đưa user input vào tên key?
- Không trực tiếp. Validate, chuẩn hoá, chặn tập giá trị, rồi băm. Rủi ro: key trùng, pattern glob sai, hash tag do người dùng điều khiển, và bùng nổ số key.
- Invalidate cache theo nhóm (vd mọi cache của category 15) thế nào?
- Tránh SCAN + DEL theo pattern. Cách tốt: đưa "version nhóm" vào tên (
category:15:ver→…:v{ver}:…), tăng số đó là mọi key cũ thành orphan và hết TTL. Hoặc cache tags (có giá: ZSET không TTL, phải prune). - Làm sao biết key nào không còn dùng?
OBJECT IDLETIME(khi policy không phải LFU) lấy mẫu theo nhóm prefix; nhóm nào idle lâu và không có trong bảng key → orphan. Phòng từ gốc: mọi key cache có TTL.- Prefix của Laravel có tác dụng với Lua không?
- Có với key truyền qua
KEYS, không với key tự ghép trong script, và không vớirawCommand. Đã test:laravel-database-outer:7nhưnginner:7.
Tóm tắt — trả lời trong 60 giây
Redis không có schema, nên tên key là schema. Em dùng một format cố định
domain:entity:version:id:phần-con, chữ thường,:chỉ làm dấu phân cách. Mọi đoạn là id hoặc giá trị từ tập hữu hạn; chuỗi tự do thì chuẩn hoá rồi băm, vì ghép thẳng có thể làm hai dữ liệu khác nhau trùng key, và bot có thể tạo hàng triệu key. Key cache luôn có TTL và có version trong tên, để hai bản code chạy song song khi deploy không đọc nhầm format của nhau. Tên dài tốn RAM thật — em đo 52 so với 95 byte/key — nhưng dưới vài triệu key em ưu tiên dễ đọc; nhiều key nhỏ thì gom hash. Trên Cluster, hash tag chỉ dùng khi cần lệnh nhiều key, và tag là id thực thể, không phải hằng số, để tránh hot slot. Cuối cùng là biết tên thật trong Redis: Laravel chồng prefix connection và prefix cache, Lua tự ghép key thì mất prefix — những cái đó phải có trong runbook.
Nguồn tham khảo
- Redis docs — Keys and values (dùng
:, tránh key quá dài, tối đa 512 MB). - Redis docs — Cluster specification (hàm
HASH_SLOT, luật hash tag). - Redis docs — ACL (
~pattern). - Laravel docs — Redis và Queues (prefix, tên queue
{default}trên Cluster). - Itamar Haber (Redis) — Redis Namespace and Other Keys to Developing with Redis: namespace bằng
:, giữ tên ngắn, SET chứa id để xoá dây chuyền. - Percona — Distributing Data in a Redis/Valkey Cluster: Slots, Hash Tags, and Hot Spots: ví dụ tag
{PENDING}dồn dữ liệu vào 3 slot. - OneUptime — Redis Key Design and Naming Conventions. Lưu ý: bài này khuyên prefix môi trường trên cùng Redis và lọc bỏ ký tự lạ khỏi input — cả hai đều có vấn đề (mục 2.2 và 2.8).
Bài liên quan: Redis — những thứ cần biết · Forum like không trùng (bảng key + CROSSSLOT) · Cache nâng cao · Cache khi rollback blue-green.