Atomic trong DB và Redis: lỗi không hiện ra khi chỉ có một người dùng
Đọc giá trị, tính toán, rồi ghi lại — đoạn code đúng trên máy local nhưng sai ngay khi có hai tiến trình cùng chạy. Atomic không phải tối ưu hiệu năng, nó là điều kiện để dữ liệu còn đúng.
- PostgreSQL
- Redis
- Concurrency
Phần lớn lỗi dữ liệu tôi từng gặp không đến từ logic sai. Logic đúng, test xanh, review ổn. Nó chỉ sai khi có hai thứ cùng chạy một lúc: hai worker nhặt cùng một job, người dùng bấm nút hai lần, cron chồng lên request từ giao diện.
Mẫu hình gần như luôn giống nhau — đọc, tính, ghi:
budget := repo.GetBudget(ctx, campaignID) // đọc
budget = budget - spend // tính
repo.SetBudget(ctx, campaignID, budget) // ghi
Ba dòng này đúng với một tiến trình. Với hai tiến trình, cả hai cùng đọc 100, cùng tính ra 70, cùng ghi 70 — và một lần trừ biến mất không để lại dấu vết. Không có lỗi, không có log, chỉ có con số sai. Khi hệ thống ghi vào ngân sách quảng cáo của khách hàng, "con số sai" là tiền thật.
Atomic nghĩa là: thao tác hoặc xảy ra trọn vẹn, hoặc không xảy ra, và không ai nhìn thấy trạng thái nửa chừng của nó. Cách đạt được điều đó khác nhau giữa database và Redis, nên tôi tách làm hai phần.
Trong database: đẩy phép tính xuống câu lệnh
Cách sửa rẻ nhất cho ví dụ trên không phải là thêm lock, mà là không đọc lên ứng dụng nữa:
UPDATE campaigns
SET budget = budget - $1
WHERE id = $2 AND budget >= $1;
Một câu UPDATE trên một dòng là atomic: PostgreSQL khoá dòng đó trong lúc ghi, tiến trình thứ hai phải chờ và sẽ thấy giá trị mới. Điều kiện budget >= $1 nằm ngay trong câu lệnh, nên không có khe hở giữa "kiểm tra" và "ghi". Ứng dụng chỉ cần nhìn số dòng bị ảnh hưởng:
tag, err := tx.Exec(ctx, q, spend, campaignID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrInsufficientBudget
}
Khi phép tính quá phức tạp để viết bằng SQL, lúc đó mới cần khoá tường minh:
BEGIN;
SELECT budget, status FROM campaigns WHERE id = $1 FOR UPDATE;
-- tính toán ở ứng dụng
UPDATE campaigns SET budget = $2 WHERE id = $1;
COMMIT;
FOR UPDATE giữ khoá dòng tới hết transaction. Đổi lại, transaction phải ngắn — không gọi API bên ngoài khi đang giữ khoá, nếu không bạn vừa biến một lỗi đúng/sai thành một lỗi treo hệ thống.
Một điều hay bị hiểu nhầm: bọc trong transaction không tự làm đoạn đọc–tính–ghi trở nên an toàn. Ở mức mặc định READ COMMITTED của PostgreSQL, hai transaction vẫn có thể cùng SELECT ra giá trị cũ. Transaction cho bạn tính toàn vẹn khi rollback; còn chống ghi đè lẫn nhau thì cần ghi có điều kiện, FOR UPDATE, hoặc mức cô lập cao hơn kèm logic retry.
Ràng buộc là lớp atomic bạn quên mất
Với bài toán "chỉ làm một lần", câu trả lời thường không phải lock mà là unique constraint:
INSERT INTO scheduled_runs (schedule_id, run_at)
VALUES ($1, $2)
ON CONFLICT (schedule_id, run_at) DO NOTHING;
Hai worker cùng nhặt một lịch chạy thì chỉ một bên chèn được dòng; bên còn lại nhận 0 rows và dừng. Database kiểm tra ràng buộc một cách atomic, bất kể bao nhiêu tiến trình, bao nhiêu máy. Đây là thứ tôi tin hơn mọi cơ chế lock ở tầng ứng dụng.
Trong Redis: từng lệnh là atomic, chuỗi lệnh thì không
Redis thực thi lệnh tuần tự, nên mỗi lệnh đơn lẻ là atomic. INCR, DECRBY, HINCRBY, SETNX không bao giờ bị chen ngang. Vấn đề bắt đầu khi bạn ghép nhiều lệnh lại bằng code ứng dụng:
// Sai: hai client cùng GET ra 9, cùng SET thành 10
n, _ := rdb.Get(ctx, key).Int()
rdb.Set(ctx, key, n+1, time.Minute)
// Đúng: một lệnh
rdb.Incr(ctx, key)
Rate limit là ví dụ kinh điển. INCR rồi EXPIRE bằng hai lệnh riêng nghĩa là nếu tiến trình chết giữa chừng, key không bao giờ hết hạn và người dùng bị chặn vĩnh viễn. Hai lệnh này phải đi cùng nhau.
MULTI/EXEC không phải transaction như trong SQL
MULTI ... EXEC đảm bảo các lệnh chạy liền một mạch, không lệnh của client khác chen vào. Nhưng nó có hai giới hạn hay làm người ta bất ngờ:
- Không có rollback. Nếu một lệnh lỗi lúc chạy (ví dụ
INCRtrên một key kiểu hash), các lệnh còn lại vẫn được thực thi. - Không đọc được giữa chừng. Bạn không thể lấy kết quả lệnh thứ nhất để quyết định lệnh thứ hai, vì mọi lệnh chỉ chạy khi
EXEC.
Muốn "đọc rồi quyết định" thì dùng WATCH (optimistic locking: key bị đổi thì EXEC thất bại và bạn thử lại), hoặc gọn hơn — dùng Lua.
Lua script: atomic cho logic có điều kiện
Một script Lua chạy trọn vẹn trên server, không lệnh nào chen vào. Đây là cách đúng để làm bất kỳ thao tác "kiểm tra rồi ghi" nào trên Redis:
-- KEYS[1] = khoá rate limit, ARGV[1] = giới hạn, ARGV[2] = TTL (giây)
local n = redis.call('INCR', KEYS[1])
if n == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if n > tonumber(ARGV[1]) then
return 0
end
return 1
Cái giá: trong lúc script chạy, Redis không phục vụ ai khác. Script phải ngắn, không lặp qua tập dữ liệu lớn.
Distributed lock: phần mở khoá mới là phần dễ sai
Lấy khoá thì ai cũng biết — một lệnh duy nhất, có token ngẫu nhiên và TTL:
SET lock:campaign:123 <token> NX PX 30000
Lỗi thường nằm ở lúc trả khoá. Nếu job chạy lâu hơn TTL, khoá đã hết hạn và một worker khác đã lấy được; lúc đó DEL thẳng tay sẽ xoá khoá của người khác. Mở khoá phải so token rồi mới xoá, và hai bước đó phải atomic:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
Thêm một chi tiết khi chạy Redis Cluster: script và MULTI chỉ atomic trên các key cùng hash slot. Muốn thao tác trên nhiều key, đặt chúng chung một hash tag, ví dụ {campaign:123}:budget và {campaign:123}:lock.
Nguyên tắc tôi giữ lại
- Nhìn thấy đọc–tính–ghi ở ứng dụng là dừng lại hỏi: có thể đẩy xuống một câu lệnh không?
- Ưu tiên ràng buộc của database hơn lock ở tầng ứng dụng. Constraint không hết hạn, không phụ thuộc đồng hồ.
- Trong Redis, một lệnh hoặc một script — đừng ghép các lệnh lại bằng code Go rồi hy vọng.
- Redis để giảm tải, database để đúng. Lock Redis giúp tránh làm việc thừa; tính đúng đắn của dữ liệu tiền bạc vẫn nằm ở Postgres.
- Test bằng song song thật, không phải bằng đọc code. Chạy cùng một thao tác từ vài chục goroutine rồi kiểm tra tổng — lỗi race hiếm khi lộ ra theo cách nào khác.
Atomic không phải một kỹ thuật nâng cao để dành cho hệ thống lớn. Nó là ranh giới giữa code "chạy được trên máy tôi" và code vẫn đúng khi có người thứ hai bấm nút.