Tình huống thực tế
Lúc 2 giờ sáng. Hệ thống monitoring cảnh báo — lỗi Redis tăng đột biến. Bạn tail log và thấy dòng này lặp đi lặp lại:
ERR offset is out of range
Tính năng bị lỗi là bộ đếm người dùng hoạt động hằng ngày (DAU) xây dựng trên Redis bitmap — mỗi user ID chiếm một bit. Trên staging hoạt động tốt. Trên production, user ID đã lên đến hàng chục triệu, tiến dần đến hàng tỷ. Đó chính là vấn đề.
Nguyên nhân gây ra lỗi
Redis báo lỗi ERR offset is out of range trong hai trường hợp riêng biệt:
- SETBIT / GETBIT / BITCOUNT / BITPOS: Offset bit vượt quá
2^32 - 1(4.294.967.295), tương đương một chuỗi 512MB. Offset âm cũng gây ra lỗi này. - SETRANGE / GETRANGE: Offset âm, hoặc chuỗi kết quả vượt quá giới hạn cứng 512MB của Redis.
Nói thẳng vào con số: bit offset 4.294.967.295 cần đúng 512MB bộ nhớ. Redis giới hạn tất cả chuỗi ở mức 512MB — bitmap thực chất cũng chỉ là chuỗi. Vượt qua ngưỡng đó một bước là bạn chạm tường ngay.
Quá trình debug
Bước 1: Tái hiện lỗi trong redis-cli
Mở redis-cli và kiểm tra hành vi tại ranh giới giới hạn:
# Offset ngay tại giới hạn — hoạt động bình thường
SETBIT dau:2025-01-15 4294967294 1
# (integer) 0
# Vượt giới hạn một bước — gây ra lỗi
SETBIT dau:2025-01-15 4294967296 1
# (error) ERR offset is out of range
# Offset âm — luôn không hợp lệ
SETBIT dau:2025-01-15 -1 1
# (error) ERR offset is out of range
# SETRANGE đẩy vượt quá 512MB
SETRANGE mykey 536870912 "x"
# (error) ERR offset is out of range
Bước 2: Tìm offset thực tế mà code đang gửi đi
Thông báo lỗi của Redis không kèm giá trị offset — bạn phải tự ghi lại trước khi lệnh đến Redis. Bọc thao tác bị lỗi bằng debug logging:
# Python (redis-py)
import redis
import logging
r = redis.Redis(host='localhost', port=6379)
def track_user_active(date_key, user_id):
logging.debug(f"SETBIT {date_key} offset={user_id}")
r.setbit(date_key, user_id, 1)
// Node.js (ioredis)
async function trackUserActive(dateKey, userId) {
console.debug(`SETBIT ${dateKey} offset=${userId}`);
await redis.setbit(dateKey, userId, 1);
}
Kích hoạt request bị lỗi và ghi lại offset. Nếu user_id có giá trị như 5000000000, bạn đã tìm ra nguyên nhân gốc rễ — nó vượt quá giới hạn bitmap 4.294.967.295.
Bước 3: Truy tìm nguồn gốc của offset
Offset vượt phạm vi hầu như luôn bắt nguồn từ một trong những trường hợp sau:
- ID tự tăng trong database đã vượt quá 4 tỷ
- Unix timestamp tính bằng millisecond (timestamp hiện tại ~1,75 nghìn tỷ — vượt xa giới hạn)
- Phép tính cho ra giá trị âm (ví dụ: trừ một base offset khỏi số nhỏ hơn)
- Số nguyên 64-bit truyền thẳng vào mà không kiểm tra phạm vi
# Kiểm tra giá trị ID thực tế của bạn
python3 -c "import time; print(int(time.time() * 1000))" # timestamp milliseconds
# 1736899200000 ← đây là 1,7 nghìn tỷ, vượt xa giới hạn
python3 -c "print(int(time.time()))" # timestamp seconds
# 1736899200 ← giá trị này ổn, dưới 4,29 tỷ
Các giải pháp
Giải pháp 1: Kiểm tra offset trước khi gọi Redis (xử lý ngay lập tức)
Thêm một lớp kiểm tra trong code ứng dụng — từ chối mọi giá trị ngoài phạm vi hợp lệ trước khi đến Redis:
# Python
MAX_BIT_OFFSET = 2**32 - 1 # 4,294,967,295
def track_user_active(date_key, user_id):
if user_id MAX_BIT_OFFSET:
raise ValueError(f"user_id {user_id} is out of Redis bitmap range (0-{MAX_BIT_OFFSET})")
r.setbit(date_key, user_id, 1)
// Node.js
const MAX_BIT_OFFSET = 4294967295;
async function trackUserActive(dateKey, userId) {
if (userId MAX_BIT_OFFSET) {
throw new RangeError(`userId ${userId} is out of Redis bitmap range`);
}
await redis.setbit(dateKey, userId, 1);
}
Giải pháp 2: Phân đoạn bitmap lớn (xử lý user ID vượt 4 tỷ)
Nếu user ID thực sự vượt quá 4 tỷ, hãy chia bitmap thành các đoạn. Mỗi key đoạn bao gồm một dải lên đến 4.294.967.295 ID:
# Python — bitmap phân đoạn
SEGMENT_SIZE = 4_294_967_295
def track_user_active_segmented(date_key, user_id):
segment = user_id // SEGMENT_SIZE
local_offset = user_id % SEGMENT_SIZE
key = f"{date_key}:seg:{segment}"
r.setbit(key, local_offset, 1)
def is_user_active_segmented(date_key, user_id):
segment = user_id // SEGMENT_SIZE
local_offset = user_id % SEGMENT_SIZE
key = f"{date_key}:seg:{segment}"
return bool(r.getbit(key, local_offset))
def count_active_users(date_key, num_segments=10):
total = 0
for seg in range(num_segments):
key = f"{date_key}:seg:{seg}"
total += r.bitcount(key)
return total
Đánh đổi: BITCOUNT giờ phải duyệt qua nhiều segment key. Với số đoạn tối đa đã biết trước, đó chỉ là một lần gọi thêm không đáng kể — hoàn toàn chấp nhận được cho hầu hết workload production.
Giải pháp 3: Dùng HyperLogLog để ước lượng cardinality
Nếu bạn chỉ cần đếm số lượng phần tử duy nhất xấp xỉ (sai số ~0,81%) và không cần tra cứu từng user, HyperLogLog hoàn toàn tránh được vấn đề offset:
# Không có vấn đề offset — chỉ cần thêm user ID như phần tử
PFADD dau:2025-01-15 "user:5000000000" "user:8000000000"
# (integer) 1
PFCOUNT dau:2025-01-15
# (integer) 2
# Dùng tối đa 12KB bộ nhớ bất kể cardinality
DEBUG OBJECT dau:2025-01-15
# serializedlength:304 ← rất nhỏ
Giải pháp 4: Ánh xạ user ID vào không gian hash có giới hạn
Hash mỗi user ID vào một dải bitmap có kích thước cố định. Phù hợp nhất khi ID thưa thớt và bạn cần dự đoán được mức sử dụng bộ nhớ:
# Python — hash vào phạm vi cố định
import hashlib
BITMAP_SIZE = 10_000_000 # 10 triệu slot, điều chỉnh theo quy mô DAU của bạn
def track_user_active_hashed(date_key, user_id):
# Hash xác định — cùng user_id luôn ánh xạ vào cùng bit
h = int(hashlib.sha256(str(user_id).encode()).hexdigest(), 16)
offset = h % BITMAP_SIZE
r.setbit(date_key, offset, 1)
Lưu ý: hash collision khiến hai user khác nhau có thể ánh xạ vào cùng một bit, nên BITCOUNT cho kết quả chặn dưới, không phải con số chính xác. Chấp nhận được cho các dashboard DAU ước lượng.
Giải pháp 5: SETRANGE với giới hạn offset an toàn
Nếu lỗi đến từ SETRANGE với offset lấy từ đầu vào người dùng hoặc các vị trí được tính toán:
# Chuỗi tối đa Redis: 512MB = 536.870.912 bytes
MAX_STRING_OFFSET = 536_870_911
def safe_setrange(key, offset, value):
if offset MAX_STRING_OFFSET:
raise ValueError(f"SETRANGE offset {offset} out of range (0-{MAX_STRING_OFFSET})")
r.setrange(key, offset, value)
Xác nhận bản sửa lỗi
Deploy bản sửa lỗi, sau đó chạy các kiểm tra cơ bản sau:
# Kiểm tra tại ranh giới hợp lệ
SETBIT dau:test 4294967294 1
# (integer) 0 ← thành công
# Xác nhận key tồn tại với kích thước đúng
STRLEN dau:test
# (integer) 536870912 ← đúng 512MB (bitmap cho 4294967295 bit)
# BITCOUNT phải hoạt động bình thường
BITCOUNT dau:test
# (integer) 1
# Xác nhận bit đã được set
GETBIT dau:test 4294967294
# (integer) 1
# Dọn dẹp key test
DEL dau:test
# (integer) 1
Mở dashboard monitoring lỗi — các mục ERR offset is out of range phải im lặng. Khoảng mười đến mười lăm phút traffic thực là đủ để xác nhận bản sửa lỗi đã hoạt động.
Bài học rút ra
Lỗi này hầu như luôn xuất phát từ việc xem Redis bitmap như mảng vô hạn. Chúng giới hạn ở 512MB — không có ngoại lệ.
- Đừng bao giờ dùng ID ngoài trực tiếp làm bitmap offset mà không kiểm tra giới hạn. User ID, order ID, và timestamp (đặc biệt timestamp millisecond) đều có thể dễ dàng vượt 4 tỷ.
- Staging sẽ không phát hiện ra lỗi này nếu pool ID trên staging còn nhỏ. Lỗi chỉ xuất hiện khi ID vượt ngưỡng 4,29 tỷ trên production. Hãy thêm kiểm tra giới hạn vào code ngay từ ngày đầu.
- Timestamp millisecond làm offset là một cái bẫy. Unix time tính bằng millisecond đã ở mức ~1,75 nghìn tỷ — luôn dùng seconds (~1,75 tỷ vào năm 2026, còn dư khoảng ~80 năm nữa) hoặc một giá trị dẫn xuất nhỏ hơn.
- Offset âm luôn không hợp lệ. Nếu bạn tính offset bằng phép tính số học (ví dụ: trừ một giá trị base), hãy thêm kiểm tra rõ ràng
>= 0trước mọi thao tác bit với Redis. - Với cơ sở người dùng ngày càng tăng, hãy thiết kế phân đoạn bitmap từ đầu — việc bổ sung sau khi ID vượt 4 tỷ đồng nghĩa với phải migrate dữ liệu trong áp lực.

