Sorted Set và Geo — bảng xếp hạng, job hẹn giờ, độ chính xác của score, tìm quanh đây
Sorted set (zset) là kiểu "đắt nhất" về RAM nhưng giải được những việc database làm vất vả: xếp hạng tức thì, top N, "trong khoảng thời gian", hàng đợi theo giờ chạy. Geo không phải kiểu riêng — nó là zset với score là geohash. Bài này đi vào những chỗ sai âm thầm: bằng điểm thì ai đứng trên, score lớn mất chính xác, nhiều worker cùng lấy job hẹn giờ, khoảng cách lệch nửa phần trăm, đảo nhầm lat/lon. Ví dụ: bảng xếp hạng khoá học, nhắc học theo lịch, gợi ý tìm khoá học (LMS); cửa hàng gần nhất (e-commerce). Mọi số đo có đối chứng PostgreSQL 18.4 thật. Nền tảng ở bài 1.
Thuật ngữ dùng trong bài
score (số thực 64 bit đi kèm mỗi member, dùng để sắp xếp) · rank (thứ hạng: vị trí trong thứ tự, đếm từ 0) · skiplist (danh sách liên kết nhiều tầng; tầng trên "nhảy cóc" qua nhiều node) · span (số node mà một liên kết nhảy qua — cộng lại ra rank) · lexicographic (so theo thứ tự byte, như từ điển) · IEEE 754 double (số thực 64 bit; số nguyên chính xác tới 2⁵³) · geohash (mã hoá kinh độ/vĩ độ thành một số bằng cách xen kẽ bit, điểm gần nhau thường có mã gần nhau) · Haversine (công thức khoảng cách trên mặt cầu) · Vincenty / WGS84 (công thức trên ellipsoid — hình dạng Trái Đất thật hơn, dùng làm mốc) · claim (giành quyền xử lý một job) · delayed job (job hẹn giờ chạy).
Kiểm chứng: tài liệu Redis qua Context7 ngày 03/10/2026 (trang sorted sets — "implemented via a dual-ported data structure containing both a skip list and a hash table",ZRANGEBYSCORE— phần tử cùng score xếp theo thứ tự từ điển, "Limits of the score" ±2⁵³,GEOSEARCH,GEOSEARCHSTORE,ZUNIONAGGREGATE COUNTtừ 8.8). Source Redis 8.8.0:server.h(ZSKIPLIST_MAXLEVEL 32,ZSKIPLIST_P 0.25). Bình luận của antirez trên Hacker News (3/2010, item 1171468) về lý do chọn skiplist. Lab chạy thật: Redis 8.8.0, PostgreSQL 18.4 (my-test-pgdb), PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64. Output:labs/redis/out/05*.txt.
Trả lời 30 giây
Zset = tập member duy nhất, mỗi member một scoredouble, luôn giữ thứ tự theo (score, rồi byte của member) bằng skiplist + dict — vì thế tốn ~86 byte/member nhưngZREVRANKchỉ 15 µs trên 1 triệu member (PostgreSQL đếm hạng: 103,6 ms). Ba bẫy âm thầm: (1) bằng điểm thì xếp theo tên, không theo ai đến trước — và tên tiếng Việt có dấu xếp sau mọi chữ không dấu; (2) score chính xác tới 2⁵³ — ghép "điểm × 10¹⁰ + thời gian" làm hai người khác nhau thành bằng điểm và đảo thứ tự; (3) nhiều worker lấy job hẹn giờ bằng "đọc rồi xử lý" thì 1 000 job chạy 8 927 lần — phải đểZREMquyết định ai chạy, vàZPOPMINlấy cả job chưa tới giờ. Geo là zset với geohash 52 bit: sai vị trí ~0,25 m, khoảng cách tính trên mặt cầu lệch ~0,5% so với WGS84, đảo lat/lon ở châu Âu không báo lỗi.
1. Bài toán gốc và bối cảnh ra đời
Bài toán: dữ liệu cần luôn có thứ tự và được cập nhật liên tục — điểm thi đua, lượt bán, thời điểm cần chạy. Trong database: top N thì có index là nhanh, nhưng
"tôi đứng thứ mấy" là đếm tất cả người hơn mình (lab PostgreSQL: 103,6 ms trên 1 triệu dòng), còn window function cho cả bảng 563,6 ms; mỗi lần cộng điểm là cập nhật index B-tree. Redis
có zset từ 1.2 (2009). antirez giải thích (Hacker News, 3/2010) vì sao chọn skiplist thay vì cây cân bằng: tốn ít bộ nhớ hơn khi chỉnh xác suất tầng, ZRANGE đi như danh sách
liên kết, và đơn giản tới mức một bản vá "skiplist có span" đủ để ZRANK thành O(log N). Geo (3.2, 2016) dựng thẳng trên zset: toạ độ → geohash → score.
Tối ưu cho: thứ hạng và lấy theo khoảng (điểm, thời gian, chữ cái đầu) khi dữ liệu cập nhật liên tục; top N; hàng đợi theo thời điểm; tìm điểm trong bán kính. Không tối ưu cho: lọc nhiều tiêu chí cùng lúc (chỉ có một trục score); thứ hạng theo nhiều khoá phụ; dữ liệu lớn hơn một node; hình học phức tạp (polygon).
2. Mô hình tư duy cốt lõi
Zset = một tập member duy nhất, mỗi member gắn một score; Redis giữ nó luôn sắp xếp theo (score, byte của member) bằng hai cấu trúc song song: dict (member → score) và skiplist (thứ tự).
flowchart LR Z["zset = (score, member) luôn sắp xếp"] --> D["dict: member → score
ZSCORE, ZADD cập nhật: O(1) tìm"] Z --> S["skiplist có span
ZRANK, ZRANGE, BYSCORE: O(log N)"] S --> U1["Bảng xếp hạng: ZINCRBY + ZREVRANK"] S --> U2["Job hẹn giờ: score = giờ chạy"] S --> U3["Sliding window, timeline: score = thời điểm"] S --> U4["Autocomplete: mọi score = 0, BYLEX"] S --> U5["Geo: score = geohash 52 bit"]
Hệ quả: member là khoá duy nhất (ghi lại cùng member = đổi score, không thêm dòng — zset không làm event log được); thứ tự khi bằng điểm là thứ tự byte, cố định, không phải thứ tự đến; score là double nên mọi mẹo "nhét nhiều thông tin vào score" đều đụng giới hạn 2⁵³; mỗi phần tử trả hai lần phí cấu trúc (85,7 byte/member ở bài 1).
3. Internals và quyết định thiết kế
3.1 Skiplist có span: vì sao rank là O(log N)
server.h). Mỗi liên kết ghi "nhảy qua bao nhiêu node". Tìm một score:
ở mỗi tầng đi tiếp khi node kế chưa vượt đích, vượt thì xuống tầng. Cộng span trên đường đi ra rank — O(log N) mà không cần đếm. Score → member cần cấu trúc thứ hai (dict) — vì thế zset tốn RAM gấp đôi
hash. Hình minh hoạ đường đi rút gọn; số span là ví dụ, không phải dump thật.3.2 Bằng điểm thì ai đứng trên?
ZADD lần lượt: zed (đến trước), amy, Bob — cùng 100 điểm; kim 90 (05a-order.txt)
ZRANGE lb 0 -1 WITHSCORES → kim 90 Bob 100 amy 100 zed 100
ZRANGE lb 0 -1 REV WITHSCORES → zed 100 amy 100 Bob 100 kim 90 ← top 1 là zed vì 'z' > 'a' > 'B'
ZREVRANK lb zed / amy / Bob → 0 / 1 / 2
tên tiếng Việt cùng điểm, ZRANGE vn 0 -1 → An Bình Dũng Ánh Đức (so byte UTF-8: Á = C3 81, Đ = C4 90 đứng sau mọi chữ ASCII)
Docs nói rõ phần tử cùng score "xếp theo thứ tự từ điển" — nhưng ít ai đọc ra hệ quả: chiều REV đảo cả thứ tự tên, nên người đứng đầu khi hoà điểm là người có
tên/id lớn nhất. Nếu luật sản phẩm là "bằng điểm, ai đạt trước đứng trên", phải mã hoá điều đó vào score (mục 3.3) hoặc xử lý khi hiển thị.
3.3 Score là double: chính xác tới 2⁵³
### số nguyên lớn (05b-precision.txt)
ZADD 2^53 a, 2^53+1 b → ZSCORE a = 9007199254740992, ZSCORE b = 9007199254740992 ← b bị làm tròn, a và b bằng điểm
### ghép 'điểm × 10^10 + (9999999999 − giây)' để ai đạt trước xếp trên
u1: 50000000 điểm, đạt lúc 1790000000 → score muốn ghi 500000008209999999 → Redis lưu 500000008210000000
u2: 50000000 điểm, đạt lúc 1790000001 → score muốn ghi 500000008209999998 → Redis lưu 500000008210000000
u3: 50000000 điểm, đạt lúc 1790000040 → score muốn ghi 500000008209999959 → Redis lưu 500000008209999936
ZRANGE comp 0 -1 REV WITHSCORES → u2 500000008210000000 u1 500000008210000000 u3 500000008209999936 ← u1 đạt trước nhưng đứng sau u2
### cách giữ được: điểm nhỏ hơn 2^21 (2 097 151) thì 'điểm × 2^32 + (2^32 − 1 − giây)' vẫn < 2^53
v1 → ghi 8589937096967295, Redis lưu 8589937096967295
v2 → ghi 8589937096967294, Redis lưu 8589937096967294
ZRANGE comp2 0 -1 REV → v1 v2
### ZINCRBY số thực
10 lần ZINCRBY f 0.1 x → ZSCORE = 0.9999999999999999
ZADD f nan y → ERR value is not a valid float | ZADD f inf z → 1, ZSCORE z = inf
Ghép nhiều thứ vào một score là mẹo phổ biến cho tie-break. Nó đúng khi tổng số bit cần ≤ 53: ví dụ 21 bit cho điểm + 32 bit cho thời gian. Vượt qua thì double làm tròn — hai
người khác nhau thành bằng điểm, rồi luật "bằng điểm xếp theo tên" (mục 3.2) đảo thứ tự. Lỗi chỉ xuất hiện khi điểm đủ lớn — tức là sau nhiều tháng chạy (nổ trễ). Điểm lẻ thì cộng
dồn bằng số nguyên theo đơn vị nhỏ, đừng ZINCRBY 0.1.
3.4 Chi phí lệnh trên zset 1 triệu member
### zset 1 000 000 member (lb), RAM 84971928 byte (05c-cost.txt)
lệnh µs
ZINCRBY lb 5 u123456 28
ZSCORE lb u123456 10
ZREVRANK lb u123456 15
ZRANGE lb 0 9 REV WITHSCORES 18
ZRANGE lb 511028 511038 REV WITHSCORES 27 ← "quanh tôi": hạng ± 5
ZRANGE lb 500000 500100 BYSCORE LIMIT 0 10 44
ZCOUNT lb 400000 600000 26 ← đếm 200 290 member nhờ span, không duyệt
ZRANGE lb 400000 600000 BYSCORE 58364 ← trả 200 290 member
ZRANGESTORE top lb 0 999 REV 728
ZREMRANGEBYRANK top 100 -1 109
ZINTERCARD 2 lb lb2 723392
ZUNIONSTORE u 2 lb lb2 6633453 ← 6,6 giây chặn cả server
Mọi thao tác trên một member hay một khoảng nhỏ là 10–44 µs bất kể kích thước. ZCOUNT đếm 200 000 member trong 26 µs vì cộng span. Chỗ đắt là trả nhiều phần tử và phép
tập hợp: ZUNIONSTORE hai zset 1 triệu mất 6,6 giây — gấp 5 lần SUNIONSTORE cùng cỡ ở bài 3, vì phải dựng cả skiplist kết quả.
3.5 Đối chứng PostgreSQL 18.4: cùng bảng xếp hạng 1 triệu dòng
### PostgreSQL 18.4: bảng tmp_lb 1 000 000 dòng, index (points DESC, user_id) (05d-pg.txt)
--- top 10
Limit (actual time=0.114..0.118 rows=10.00 loops=1)
-> Index Only Scan using tmp_lb_points on tmp_lb (actual time=0.113..0.115 rows=10.00 loops=1)
Execution Time: 0.168 ms
--- hạng của user 123456: đếm số người điểm cao hơn
Finalize Aggregate (actual time=98.326..103.417 rows=1.00 loops=1)
Workers Launched: 2
-> Parallel Index Only Scan using tmp_lb_points on tmp_lb (actual time=0.231..66.864 rows=328745.33 loops=3)
Execution Time: 103.622 ms
--- cộng điểm (cập nhật index)
Update on tmp_lb (actual time=0.248..0.249 rows=0.00 loops=1)
Execution Time: 0.478 ms
--- hạng bằng window function cho cả bảng (để so)
Execution Time: 563.598 ms
| Thao tác (1 triệu người) | PostgreSQL 18.4 | Redis zset | Ghi chú |
|---|---|---|---|
| Top 10 | 0,17 ms (index only scan) | 0,018 ms | Cả hai đều nhanh — top N không phải lý do để thêm Redis |
| Hạng của một người | 103,6 ms (đếm ~986 000 mục index, 3 process) | 0,015 ms | Đây mới là lý do: B-tree không lưu "số phần tử bên trái" |
| Cộng điểm | 0,48 ms (+ commit/WAL) | 0,028 ms | PG bền vững, Redis thì không |
| Hạng mọi người | 563,6 ms (window function) | — (lấy từng người, O(log N)) |
Ghi chú: hạng bằng đếm chỉ nhanh với người top đầu (ít người hơn mình); user 123456 trong lab có điểm thấp nên phải đếm gần hết bảng. Đó là trường hợp xấu nhưng là trường hợp thường gặp nhất (đa số người dùng không ở top).
3.6 Job hẹn giờ: score = giờ chạy, và ai được chạy?
### 1 000 job tới hạn + 100 job hẹn 1 giờ sau, 10 worker (fork) cùng lấy, mỗi job "chạy" 0,3 ms (05e-delayed.txt)
naive ZRANGEBYSCORE 10 job tới hạn → xử lý → ZREM (không xem ZREM trả gì) → 8927 lần chạy, 1000 job chạy >1 lần, 0 job chạy SỚM, 0 job sót
zrem-claim ZRANGEBYSCORE → chỉ xử lý job mà ZREM của mình trả 1 → 1000 lần chạy, 0 job chạy >1 lần, 0 job chạy SỚM, 0 job sót
zpopmin ZPOPMIN 1 rồi xử lý ngay → 1100 lần chạy, 0 job chạy >1 lần, 100 job chạy SỚM, 0 job sót
lua Lua: ZRANGEBYSCORE + ZREM trong 1 script (kiểu Laravel migrate) → 1000 lần chạy, 0 job chạy >1 lần, 0 job chạy SỚM, 0 job sót
ZRANGEBYSCORE chỉ là đọc: mọi worker cùng thấy cùng job. Quyền chạy phải được quyết định bởi một thao tác nguyên tử chỉ một người thắng — ZREM
trả 1 cho đúng một client (hoặc gộp đọc + xoá trong Lua như Laravel). Lab: không xem kết quả ZREM thì mỗi job chạy trung bình ~9 lần.Laravel làm đúng việc này cho queue :delayed và :reserved: migrateExpiredJobs là một Lua lấy job tới hạn, xoá khỏi zset và đẩy vào list trong một bước
(xem bài 4 mục 3.2). Muốn tự làm scheduler: dùng mẫu "ai ZREM được thì chạy", và vẫn nhớ đây là at-most-once — worker chết sau ZREM là mất job;
muốn at-least-once thì chuyển job sang zset "đang chạy" có hạn, như bài 4.
3.7 Autocomplete bằng BYLEX
ZRANGE ac [lara (lara\xff BYLEX LIMIT 0 5 → lara|larastan|laravel|laravel livewire|laravel nang cao (05f-lex.txt)
gõ 'điện' (có dấu) → ZRANGE ac [điện … → (rỗng)
gõ 'dien' (bỏ dấu, phần trước ':' là khoá tìm) → dien may:điện máy|dien thoai:điện thoại
thêm 'laravel 13' với score 1 (khác 0) → … → lara|larastan|laravel|laravel livewire|laravel nang cao|laravel 13 ← BYLEX chỉ đúng khi mọi score bằng nhau
Mọi member cùng score 0 thì zset sắp theo byte → BYLEX lấy "mọi chuỗi bắt đầu bằng lara" trong O(log N + M). Với tiếng Việt, lưu khoá-bỏ-dấu:chữ-hiển-thị
và bỏ dấu chữ người dùng gõ trước khi tìm. Không xếp được theo độ phổ biến (score phải bằng nhau) — cần cả hai thì dùng Query Engine (bài 7) hoặc search engine.
3.8 AGGREGATE COUNT (8.8): mỗi member có trong bao nhiêu zset
ZUNION 3 done:c1 done:c2 done:c3 AGGREGATE COUNT WITHSCORES → sv2 1 sv4 1 sv3 2 sv1 3 (05g-count.txt)
AGGREGATE SUM (mặc định) → sv2 1 sv3 2 sv1 7 sv4 9 ← score 5, 9 làm sai nếu muốn đếm
ZINTER 2 done:c1 done:c2 AGGREGATE COUNT WITHSCORES → sv1 2 sv3 2
Trước 8.8, "đếm số tập chứa member" phải đặt mọi score = 1 rồi SUM, và hỏng ngay khi score mang ý nghĩa khác (thời điểm hoàn thành). COUNT bỏ qua score.
3.9 ZRANDMEMBER cũng lệch
ZRANDMEMBER (1 000 phần tử, skiplist, 200 000 lần): kỳ vọng 200 | ít nhất 65, nhiều nhất 378 | chi-square 13829 (đều thì ≈ 999) (05h-random.txt)
HRANDFIELD (1 000 phần tử, hashtable, 200 000 lần): kỳ vọng 200 | ít nhất 67, nhiều nhất 388 | chi-square 13436 (đều thì ≈ 999)
Cùng cơ chế chọn ngẫu nhiên trên dict như SRANDMEMBER (bài 3 mục 3.5). Rút ngẫu nhiên công bằng trong zset: app sinh số
random_int(0, ZCARD − 1) rồi ZRANGE key i i (lab bài 3: chi-square 994 ≈ 999).
3.10 Geo: geohash 52 bit trong score
### lưu thế nào (05i-geo.txt)
TYPE shops = zset, ZSCORE hoan-kiem = 3973593206939046 (geohash 52 bit), GEOHASH = w7er8u0evs0
GEOPOS hoan-kiem → 105.85420221090317 21.028499185602875 (nhập 105.8542 21.0285)
lệch giữa toạ độ nhập và GEOPOS: 0.247 m
### khoảng cách Hà Nội – TP.HCM
GEODIST (cầu, Haversine) = 1,143,827 m | Vincenty WGS84 = 1,138,050 m | lệch 5,777 m (0.51%)
### nhầm thứ tự (lat, lon)
GEOADD shops 21.0285 105.8542 x (đảo cho Hà Nội) → ERR invalid longitude,latitude pair 21.028500,105.854200
GEOADD shops 48.8566 2.3522 paris-dao (Paris đảo lat/lon) → 1 ← nhận! điểm nằm ở … lat 2.35, lon 48.86 (gần Somalia)
GEOADD shops 105 86 bac-cuc → ERR invalid longitude,latitude pair 105.000000,86.000000 (giới hạn lat ±85.05112878)
### GEOSEARCH trên 1 000 000 cửa hàng rải đều trong khung Việt Nam
RAM: 84983288 byte (84.98 byte/điểm)
BYRADIUS 1 km: 4 cửa hàng, 42 µs | COUNT 10 ASC: 42 µs | COUNT 10 ANY: 41 µs
BYRADIUS 5 km: 54 cửa hàng, 2510 µs | COUNT 10 ASC: 961 µs | COUNT 10 ANY: 38 µs
BYRADIUS 20 km: 987 cửa hàng, 1668 µs | COUNT 10 ASC: 1616 µs | COUNT 10 ANY: 153 µs
BYRADIUS 100 km: 24474 cửa hàng, 23855 µs | COUNT 10 ASC: 46831 µs | COUNT 10 ANY: 3488 µs
ZRANGEBYSCORE. Chi phí
tỉ lệ với số điểm trong 9 ô, không phải với kết quả: ở lab, bán kính 5 km tốn hơn 20 km vì cỡ ô được chọn phủ rộng hơn so với vòng tròn. Hình minh hoạ, không theo tỉ lệ.Ba điều cần nói khi phỏng vấn: (1) độ chính xác vị trí ~0,25 m (52 bit) — thừa cho "cửa hàng gần nhất"; (2) mô hình Trái Đất là mặt cầu: khoảng cách lệch ~0,5% so với WGS84 (5,8 km trên 1 144 km) — không dùng tính phí vận chuyển theo km hay pháp lý; (3) thứ tự tham số là (lon, lat) — ở Việt Nam đảo nhầm sẽ báo lỗi (lon > 85 làm lat không hợp lệ), nhưng ở châu Âu đảo nhầm vẫn được nhận, điểm rơi xuống Ấn Độ Dương — bug chỉ lộ khi mở rộng thị trường.
4. Đảm bảo và giới hạn
| Khía cạnh | Đảm bảo | Giới hạn |
|---|---|---|
| Thứ tự | Luôn theo (score, byte của member); REV đảo cả hai | Không có tie-break theo thời gian, không theo collation ngôn ngữ |
| Score | IEEE 754 double; số nguyên chính xác trong ±2⁵³; nhận ±inf | Từ chối nan; số thực cộng dồn bị trôi (0,9999999999999999) |
| Độ phức tạp | Thêm/sửa/rank/khoảng: O(log N); ZCOUNT O(log N) | Trả M phần tử: O(log N + M); ZUNIONSTORE O(N) + O(M log M) — 6,6 s ở 2 × 1 triệu |
| Nguyên tử | ZREM trả 1 cho đúng một client; ZPOPMIN không giao trùng | Đọc khoảng rồi xử lý không giành quyền (lab: 8 927 lần chạy) |
| Geo | Vị trí ~0,25 m; lat ±85,05112878 (Web Mercator) | Mặt cầu (lệch ~0,5%); không polygon; một key nằm trọn một node |
| Ngẫu nhiên | — | ZRANDMEMBER lệch (chi-square 13 829 / 999) |
Khung lý thuyết: skiplist cho kỳ vọng O(log N) với xác suất cao (không phải đảm bảo tuyệt đối như cây cân bằng) — đánh đổi lấy code đơn giản và duyệt khoảng nhanh. Rank
bằng span là "order-statistic tree" kiểu xác suất. Với scheduler, "đọc rồi xoá" là check-then-act; ZREM biến nó thành compare-and-delete — một dạng consensus cục bộ trên một node
Redis (failover vẫn có thể làm job chạy lại hoặc mất).
5. Failure modes
| # | Tình huống | Hậu quả (đo) | Phát hiện | Chữa / phòng |
|---|---|---|---|---|
| 1 | Hoà điểm, sản phẩm muốn "ai đạt trước đứng trên" | Xếp theo tên/id: zed đứng trên amy; tên có dấu xếp cuối | Khiếu nại thứ hạng | Mã hoá thời gian vào score trong 53 bit, hoặc tie-break ở tầng hiển thị |
| 2 | Score ghép vượt 2⁵³ | Hai người khác nhau thành bằng điểm, thứ tự đảo (lab u1/u2) — nổ trễ khi điểm lớn dần | Thứ hạng "nhảy" khó tái hiện | Kiểm biên số bit trong code; test với điểm max dự kiến |
| 3 | Scheduler đọc rồi chạy, nhiều worker | 1 000 job chạy 8 927 lần | Email/notification trùng | Ai ZREM được thì chạy, hoặc Lua |
| 4 | Scheduler dùng ZPOPMIN | Chạy job chưa tới giờ (lab: 100 job) | Nhắc học gửi trước giờ | Kiểm score trước, hoặc ZRANGEBYSCORE + ZREM |
| 5 | ZUNIONSTORE/ZINTERCARD zset lớn trên đường request (bảng xếp hạng tổng hợp) | 0,7–6,6 s chặn server | SLOWLOG | Tính trước bằng job, ghi key mới rồi RENAME |
| 6 | phpredis ['rev' => true] | Bị bỏ qua: top 3 ra 3 người thấp nhất, không lỗi (lab) | Chỉ thấy bằng mắt | Dạng list ['rev', 'withscores'] hoặc zrevrange; có test |
| 7 | Đảo lat/lon khi GEOADD | Lỗi ở VN; ở châu Âu được nhận, sai vị trí im lặng | Điểm hiển thị sai chỗ | Validate miền toạ độ theo quốc gia; đặt tên tham số rõ |
| 8 | Dùng GEODIST tính phí theo km | Lệch ~0,5% (5,8 km / 1 144 km) | Đối soát với bản đồ | Tính bằng thư viện geodesic/dịch vụ bản đồ |
| 9 | Zset làm event log (member = nội dung sự kiện) | Sự kiện trùng nội dung gộp làm một (ZADD cùng member = đổi score) | Thiếu sự kiện | Member có id duy nhất, hoặc stream (bài 6) |
6. Vận hành trong production
- RAM: ~85 byte/member (1 triệu = 85 MB). Bảng xếp hạng theo tuần/khoá học → mỗi kỳ một key có TTL (
lb:course:42:2026w40), không một key vĩnh viễn. - Metric:
ZCARDcác key lớn,INFO keysizes→zsets_items; SLOWLOG choZUNIONSTORE/ZRANGE … BYSCOREtrả nhiều;HOTKEYS(8.6) — bảng xếp hạng chung là hot key điển hình; độ trễ queue hẹn giờ (ZCOUNT jobs:due -inf now> 0 kéo dài = worker không theo kịp). - Đọc nhiều:
ZRANGE … REVtop N và "quanh tôi" có thể đọc từ replica (chấp nhận trễ); cache top 100 vài giây ở app nếu một key chịu hàng chục nghìn đọc/giây. - Dựng lại: bảng xếp hạng là dữ liệu dẫn xuất — dựng từ DB vào key mới bằng
ZADDtheo lô, rồiRENAMEđè key cũ (nguyên tử); không dựng tại chỗ trên key đang phục vụ. - Cluster: một zset nằm trọn một node; bảng xếp hạng toàn hệ thống quá lớn thì chia theo vùng/kỳ, hợp nhất bằng job.
- Debug lúc 3 giờ sáng: "thứ hạng sai" → kiểm score có vượt 2⁵³, hoà điểm, client có bỏ qua
rev; "nhắc học gửi trùng" → code scheduler có dùng kết quảZREMkhông.
6.1 Code Laravel (đã chạy thật)
use Illuminate\Support\Facades\Redis;
// Bảng xếp hạng tuần của khoá học
$lb = "lb:course:$courseId:" . now()->format('o\wW');
Redis::zincrby($lb, $points, "u$userId");
Redis::expire($lb, 60 * 86400);
$top = Redis::zrange($lb, 0, 9, ['rev', 'withscores']); // KHÔNG dùng ['rev' => true]: bị bỏ qua
$rank = Redis::zrevrank($lb, "u$userId"); // 0 = hạng nhất
$around = Redis::zrange($lb, max(0, $rank - 5), $rank + 5, ['rev', 'withscores']);
// Scheduler: ai ZREM được thì chạy
foreach (Redis::zrangebyscore('jobs:due', '-inf', time(), ['limit' => [0, 50]]) as $id) {
if (Redis::zrem('jobs:due', $id) === 1) {
dispatch(new SendReminder($id));
}
}
// Cửa hàng gần nhất (lon trước, lat sau)
$near = Redis::geosearch('shops', [$lon, $lat], 10, 'km', ['WITHDIST', 'ASC', 'COUNT' => 3]);
### output (05j-laravel.txt)
zrange(..., ['rev' => true, 'withscores' => true]) → {"u4":950,"u5":1200,"u1":1500} ← 'rev' => true bị BỎ QUA, ra thứ tự tăng dần
zrange(..., ['rev', 'withscores']) → {"u3":1700,"u2":1700,"u1":1500} ← đúng top 3
u5 hạng 4; quanh u5: {"u1":1500,"u5":1200,"u4":950}
remind:9: mình lấy được → chạy
geosearch 10 km quanh (105.85, 21.03): {"hoan-kiem":[0.4671],"hai-ba-trung":[3.7825],"cau-giay":[4.9998]}
geodist hoan-kiem → quan-1: 1143.8269 km
Để ý: top 3 có u3 trước u2 dù cùng 1 700 điểm — đúng luật byte ở chiều REV. Bẫy của đoạn code: zincrby rồi expire là 2 lệnh (gộp
MULTI nếu cần); scheduler là at-most-once (worker chết sau zrem thì mất nhắc); dispatch lỗi sau khi đã zrem cũng mất — đẩy lại vào zset trong
catch.
7. So sánh và ma trận quyết định
| Bài toán | Redis | Thay thế | Chọn Redis khi | Chọn thay thế khi |
|---|---|---|---|---|
| Bảng xếp hạng | Zset (15 µs rank) | PostgreSQL index (103,6 ms rank, 0,17 ms top 10) | Cần "tôi hạng mấy" realtime cho nhiều người | Chỉ cần top N, hoặc xếp hạng theo nhiều tiêu chí/bộ lọc |
| Job hẹn giờ | Zset + ZREM claim / Lua | Laravel delay() (cũng zset), bảng DB + SKIP LOCKED, SQS delay | Đã có Redis, chấp nhận mất khi failover | Không được mất lịch (nhắc thanh toán, hợp đồng) |
| Timeline / cửa sổ thời gian | Zset score = thời điểm | Stream (bài 6), TimeSeries (bài 7) | Cần xoá/sửa theo member, đếm theo khoảng | Append-only có consumer → stream; số liệu → TimeSeries |
| Autocomplete | BYLEX (score = 0) | Query Engine, Elasticsearch, pg_trgm | Tiền tố đơn giản, ít dữ liệu | Cần xếp theo độ phổ biến, sửa lỗi gõ, tiếng Việt có dấu |
| Tìm quanh đây | Geo (42 µs ở 1 km) | PostGIS, Elasticsearch geo | Bán kính/hộp, sai 0,5% chấp nhận được | Polygon, tuyến đường, khoảng cách chính xác (PostGIS chưa có trong lab — chưa verify) |
Dùng zset khi: cần thứ tự luôn sẵn + rank/khoảng nhanh trên dữ liệu cập nhật liên tục. Tránh khi: cần nhiều trục sắp xếp, cần thứ tự hoà điểm theo thời gian mà không mã hoá được trong 53 bit, cần phép hợp/giao trên tập rất lớn theo yêu cầu, hoặc dữ liệu là nguồn sự thật.
8. Góc nhìn tech lead: tác động tổ chức
- Luật xếp hạng là quyết định sản phẩm: hoà điểm xử lý thế nào, kỳ xếp hạng (tuần/tháng), làm tròn điểm — ghi vào spec. Mặc định của Redis (theo byte) hiếm khi là thứ product muốn.
- ADR cho score: nếu ghép nhiều trường vào score, ghi rõ số bit mỗi phần và giới hạn (ví dụ điểm < 2²¹), kèm test ở biên.
- Bảng xếp hạng là dữ liệu dẫn xuất: nguồn sự thật là bảng điểm trong DB; có job dựng lại; đổi luật tính điểm = dựng lại toàn bộ.
- Scheduler là hạ tầng quan trọng: đừng để mỗi team tự viết vòng lặp
ZRANGEBYSCORE. Một thư viện/service chung với claim đúng, retry, đo độ trễ. - Exit strategy: zset và geo là lõi, có trên Valkey;
AGGREGATE COUNTlà 8.8 — dùng thì đặt sau adapter.
9. Anti-patterns và bài học thực tế
- Tie-break bằng tên/id ngầm định: "sao bạn kia cùng điểm mà đứng trên tôi?".
- Score = điểm × 10¹⁰ + timestamp: đúng lúc đầu, sai khi điểm lớn.
- Scheduler đọc rồi chạy: trùng nhân theo số worker.
- Một zset "all-time leaderboard" không TTL, hàng chục triệu member: big key, hot key, không chia được.
- Tính phí giao hàng bằng
GEODIST: sai số mặt cầu.
Nguồn công khai: antirez trên Hacker News (3/2010) về skiplist; docs Redis "Limits of the score" (±2⁵³) và thứ tự từ điển khi bằng score. Chưa tìm được postmortem công khai cụ thể về scheduler trùng do zset — số liệu ở đây là lab tự dựng.
Điều docs không nói rõ: chiều REV đảo cả tie-break; tên có dấu xếp sau; phpredis bỏ qua 'rev' => true; bán kính nhỏ có thể tốn hơn bán kính lớn hơn.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Sorted set cài đặt thế nào? Vì sao skiplist?
- Nhỏ (≤ 128) là listpack; lớn là skiplist (có span) + dict. Skiplist: đơn giản, duyệt khoảng như danh sách liên kết, chỉnh xác suất tầng để đỡ RAM, span cho rank O(log N) (antirez, HN 2010).
- Hai người bằng điểm, ai đứng trên?
- Theo byte của member;
ZREVRANGEđảo cả thứ tự đó. Muốn "ai đạt trước" thì mã hoá thời gian vào score trong 53 bit hoặc xử lý ở tầng hiển thị. - Giới hạn của score?
- Double: số nguyên chính xác trong ±2⁵³. Ghép nhiều trường phải tính bit. Không nhận NaN.
- Thiết kế bảng xếp hạng cho 10 triệu người chơi?
- Zset theo kỳ (~850 MB/10 triệu — tính 85 B/member),
ZINCRBYkhi có điểm,ZREVRANK+ZRANGEquanh mình; dữ liệu gốc ở DB, dựng lại bằng job; key quá nóng thì cache top N ngắn hạn, đọc replica; quá lớn thì chia theo vùng. - Làm delayed queue bằng Redis?
- Zset score = giờ chạy; worker lấy job tới hạn và giành bằng
ZREM(hoặc Lua đọc + xoá + đẩy sang list như Laravel). Không dùngZPOPMINtrần. Cần không mất thì thêm vùng "đang chạy" có hạn. - Geo trong Redis chính xác tới đâu?
- Vị trí ~0,25 m (geohash 52 bit), khoảng cách theo mặt cầu lệch ~0,5% so với WGS84; lat giới hạn ±85,05°. Đủ cho "gần đây", không đủ cho tính tiền theo km.
- Vì sao GEOSEARCH bán kính lớn chậm?
- Chi phí theo số điểm trong 9 ô geohash phải xét;
COUNT n ANYdừng sớm (100 km: 24 ms → 3,5 ms) nhưng không bảo đảm là n điểm gần nhất.
Phụ lục: lab đã chạy
Môi trường: Redis 8.8.0 (redis:alpine), PostgreSQL 18.4 (my-test-pgdb, DB isolation_demo, bảng tạm tmp_lb tạo rồi xoá),
PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64 (project laravel-livewire mount read-only, REDIS_PREFIX=lab:). Container tmp-rdt-redis. Máy 4 vCPU dùng chung.
cd labs/redis
./05-zset-geo.sh # chạy hết → out/05a … 05j
./05-zset-geo.sh pg # 1 phần: order | precision | cost | pg | delayed | lex | count | random | geo | laravel
Chưa verify: so sánh với PostGIS (không có trong container PG của lab); scheduler khi Redis failover; độ trễ ZADD trên zset 10 triệu member; hình skiplist là minh hoạ,
không dump từ server.