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, ZUNION AGGREGATE COUNT từ 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 score double, 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ưng ZREVRANK chỉ 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 để ZREM quyết định ai chạy, và ZPOPMIN lấ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)

ZRANK đi trên skiplist và cộng span Skiplist 4 tầng với 8 node có score 10 tới 80. Mỗi liên kết ghi số node nó nhảy qua. Tìm member có score 60: bắt đầu ở tầng cao nhất, nhảy qua 4 node tới node 40, xuống tầng 3, nhảy tiếp 2 node tới node 60. Cộng span 4 + 2 = 6, nên rank là 5 tính từ 0. ZRANK của member có score 60 — cộng span trên đường đi head 10203040 50607080 4422 2211 tầng 4: nhảy 4 node (span 4) tầng 3: +2 (40 → 60) span 4 + 2 = 6 → rank 5 (đếm từ 0)
Hình 1 — Skiplist: node có số tầng ngẫu nhiên (xác suất lên tầng 1/4, tối đa 32 tầng — 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.4Redis zsetGhi chú
Top 100,17 ms (index only scan)0,018 msCả hai đều nhanh — top N không phải lý do để thêm Redis
Hạng của một người103,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ểm0,48 ms (+ commit/WAL)0,028 msPG bền vững, Redis thì không
Hạng mọi người563,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
Hai worker cùng lấy job hẹn giờ Worker A và B cùng đọc ZRANGEBYSCORE và cùng nhận j1, j2. Cách đọc rồi chạy: cả hai cùng chạy j1. Cách giành bằng ZREM: A gửi ZREM j1 nhận 1 nên chạy, B gửi ZREM j1 nhận 0 nên bỏ qua và chuyển sang j2. jobs:due = { j1: giờ − 10s, j2: giờ − 10s, j3: giờ + 1h } jobs:due (zset) j1j2j3 (1h) worker Aworker Bworker Aworker B [j1, j2] [j1, j2] ZREM j1 ZREM j1 cả hai cùng đọc thấy j1, j2 (ZRANGEBYSCORE … LIMIT 0 10) Đọc rồi chạy A chạy j1, B cũng chạy j1 → email nhắc học gửi 2 lần lab 10 worker: 1 000 job → 8 927 lần chạy Ai ZREM được thì chạy A: ZREM j1 → 1 → chạy B: ZREM j1 → 0 → bỏ qua, thử j2 lab: 1 000 job → đúng 1 000 lần ZPOPMIN không xem giờ: lấy luôn j3 hẹn 1 giờ sau (lab: 100 job chạy sớm)
Hình 2 — 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
GEOSEARCH: ô geohash quanh tâm rồi lọc theo khoảng cách Lưới ô geohash; vòng tròn bán kính tìm kiếm quanh tâm. Redis chọn 9 ô (ô chứa tâm và 8 ô xung quanh) đủ phủ vòng tròn, lấy mọi điểm trong các ô đó bằng các khoảng score liên tiếp, rồi tính khoảng cách từng điểm: điểm trong vòng tròn được giữ (xanh), điểm trong ô nhưng ngoài vòng tròn bị loại (đỏ). GEOSEARCH … BYRADIUS r — 9 ô geohash, rồi lọc từng điểm 1. chọn độ dài geohash theo bán kính, lấy 9 ô quanh tâm = 9 khoảng score 2. tính khoảng cách (Haversine) từng điểm trong ô mà ngoài vòng tròn → loại 3. COUNT n ANY dừng khi đủ n điểm (không bảo đảm là n điểm gần nhất) lab 1 triệu điểm: 1 km 42 µs; 100 km 24 ms; 100 km COUNT 10 ANY 3,5 ms
Hình 3 — Geohash xen kẽ bit kinh độ/vĩ độ: các điểm trong cùng một ô có score nằm trong một khoảng liên tiếp, nên "lấy mọi điểm trong ô" là một 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ảoGiới hạn
Thứ tựLuôn theo (score, byte của member); REV đảo cả haiKhông có tie-break theo thời gian, không theo collation ngôn ngữ
ScoreIEEE 754 double; số nguyên chính xác trong ±2⁵³; nhận ±infTừ chối nan; số thực cộng dồn bị trôi (0,9999999999999999)
Độ phức tạpThê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)
GeoVị 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ốngHậu quả (đo)Phát hiệnChữa / phòng
1Hoà đ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ốiKhiếu nại thứ hạngMã hoá thời gian vào score trong 53 bit, hoặc tie-break ở tầng hiển thị
2Score 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ầnThứ hạng "nhảy" khó tái hiệnKiểm biên số bit trong code; test với điểm max dự kiến
3Scheduler đọc rồi chạy, nhiều worker1 000 job chạy 8 927 lầnEmail/notification trùngAi ZREM được thì chạy, hoặc Lua
4Scheduler dùng ZPOPMINChạ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
5ZUNIONSTORE/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 serverSLOWLOGTính trước bằng job, ghi key mới rồi RENAME
6phpredis ['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ắtDạng list ['rev', 'withscores'] hoặc zrevrange; có test
7Đảo lat/lon khi GEOADDLỗ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õ
8Dùng GEODIST tính phí theo kmLệ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 đồ
9Zset 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ệnMember có id duy nhất, hoặc stream (bài 6)

6. Vận hành trong production

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ánRedisThay thếChọn Redis khiChọn thay thế khi
Bảng xếp hạngZset (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ườiChỉ 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 / LuaLaravel delay() (cũng zset), bảng DB + SKIP LOCKED, SQS delayĐã có Redis, chấp nhận mất khi failoverKhông được mất lịch (nhắc thanh toán, hợp đồng)
Timeline / cửa sổ thời gianZset score = thời điểmStream (bài 6), TimeSeries (bài 7)Cần xoá/sửa theo member, đếm theo khoảngAppend-only có consumer → stream; số liệu → TimeSeries
AutocompleteBYLEX (score = 0)Query Engine, Elasticsearch, pg_trgmTiền tố đơn giản, ít dữ liệuCần xếp theo độ phổ biến, sửa lỗi gõ, tiếng Việt có dấu
Tìm quanh đâyGeo (42 µs ở 1 km)PostGIS, Elasticsearch geoBán kính/hộp, sai 0,5% chấp nhận đượcPolygon, 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

9. Anti-patterns và bài học thực tế

  1. Tie-break bằng tên/id ngầm định: "sao bạn kia cùng điểm mà đứng trên tôi?".
  2. Score = điểm × 10¹⁰ + timestamp: đúng lúc đầu, sai khi điểm lớn.
  3. Scheduler đọc rồi chạy: trùng nhân theo số worker.
  4. Một zset "all-time leaderboard" không TTL, hàng chục triệu member: big key, hot key, không chia được.
  5. 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), ZINCRBY khi có điểm, ZREVRANK + ZRANGE quanh 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ùng ZPOPMIN trầ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 ANY dừ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.