Skip to content

Nhất quán giữa cache và database ​

Cache và database là hai hệ thống độc lập, nối với nhau qua mạng. Ghi xong vào database không tự làm cache đổi theo. Gộp cả hai vào một transaction phân tán (2PC) thì quá chậm, nên mục tiêu thực tế là nhất quán cuối cùng: có thể lệch trong thời gian ngắn, nhưng phải tự hội tụ, và khoảng lệch phải có giới hạn.

Xoá hay cập nhật cache? ​

Khi database đổi, có hai lựa chọn: cache.set(key, giá_trị_mới) hoặc cache.del(key). Chọn xoá. Hai thread cùng sửa giá, mỗi thread ghi database rồi SET cache; nếu lệnh SET của thread ghi trước tới muộn (mạng chậm), cache giữ giá trị cũ tới khi hết TTL. Nếu cả hai đều xoá, kết quả cuối luôn là cache trống, và lần đọc sau nạp đúng giá trị từ database.

Ghi database trước hay xoá cache trước? ​

Ghi database trước, xoá cache sau. Xoá cache trước thì có khoảng hở: một reader thấy miss, đọc database (vẫn là giá cũ), rồi nạp giá cũ vào cache, sau đó writer mới ghi database.

Ngay cả khi làm đúng thứ tự, vẫn còn ba trường hợp gây lệch:

  • Reader bị dừng giữa chừng (stale set, “zombie reader”): reader miss, đọc database được giá cũ, bị GC pause; trong lúc đó writer ghi database và xoá cache (đang trống); reader tỉnh lại và SET giá cũ. Hiếm, nhưng có thật.
  • Replication lag: ghi vào primary, đọc từ replica. Writer xoá cache; reader miss, đọc replica chưa kịp cập nhật, nạp giá cũ vào cache. Replica cập nhật xong cũng không còn ai xoá cache nữa (MySQL chương 9).
  • Ghi database xong, xoá cache thất bại: timeout, app crash giữa hai bước. Không có transaction chung cho hai nơi. Retry mãi thì chặn request; thường chỉ retry vài lần.

Chọn kịch bản, bấm từng bước và nhìn ba ô dữ liệu:

Kịch bản gây sai lệch
Cách khắc phục
Cập nhật cache thay vì xoá

Hai thread cùng đổi giá. Mỗi thread ghi database rồi SET giá mới vào cache.

Database50nguồn sự thật
Cache50khớp database
0/4
1Thread AUPDATE price = 100 trong database
2Thread BUPDATE price = 200 trong database
3Thread Bcache.set(price, 200)
4Thread Acache.set(price, 100), tới muộn do mạng chậm

Các giải pháp ​

Delayed double delete ​

Ghi database, xoá cache, rồi xoá thêm lần nữa sau thời gian T (đẩy việc xoá lần hai cho worker nền hoặc hàng đợi, không sleep trong request).

  • Chọn p99 replication lag + p99 thời gian đọc < T < TTL. T quá nhỏ thì không bắt được lỗi; T quá lớn (10 phút “cho chắc”) thì dữ liệu sai tồn tại đúng chừng ấy thời gian.
  • Không chặn được trường hợp reader dừng lâu hơn T, và không cứu được trường hợp app crash trước khi hẹn lần xoá thứ hai.

Xoá cache qua CDC ​

App chỉ ghi database. Công cụ CDC (Debezium, Canal) đọc binlog/WAL, đẩy sự kiện vào Kafka; consumer xoá key tương ứng (MySQL chương 9).

  • Giải quyết “ghi xong mà xoá thất bại”: chỉ khi database ghi thành công mới có sự kiện; xoá lỗi thì hàng đợi retry tới khi xong. Code app không phải lo xử lý lỗi cache.
  • Đọc binlog ở đâu quyết định chuyện replication lag. Nếu consumer đọc binlog của primary, lệnh xoá có thể chạy trước khi replica có dữ liệu mới, và reader vẫn nạp lại giá cũ từ replica. Nếu consumer đọc binlog của chính replica mà app đọc, lệnh xoá chỉ chạy khi replica đã mới, nên không reader nào nạp được giá cũ sau lệnh xoá.
  • Chế độ cập nhật thay vì xoá: consumer SET giá trị mới vào cache. Chỉ làm được khi cache lưu đúng dữ liệu của một dòng; cache lưu object tổng hợp từ nhiều bảng (hồ sơ người dùng kèm số đơn, địa chỉ…) thì một sự kiện binlog không đủ dữ liệu để dựng lại. Vẫn còn race nếu reader đọc replica cũ rồi SET đè sau CDC, và phải partition Kafka theo key để giữ thứ tự cập nhật của cùng một key.
  • Đổi lại: thêm hạ tầng (CDC, Kafka), và dữ liệu cũ sống thêm đúng bằng độ trễ của đường ống này.

Lease (token cấp quyền ghi) ​

Facebook mô tả trong bài báo Scaling Memcache at Facebook (NSDI 2013). Khi đọc miss, cache cấp cho reader một token duy nhất. Reader chỉ được ghi giá trị vào cache nếu token của nó vẫn còn hiệu lực; lệnh xoá của writer huỷ luôn token đang tồn tại.

  • Chặn được reader bị dừng giữa chừng: token của nó đã bị huỷ, lệnh ghi bị từ chối.
  • Token phải duy nhất cho mỗi lần cấp (UUID hoặc số tăng dần). Dùng cùng một chuỗi cố định cho mọi lease (ví dụ "WORKING") thì phép so sánh-rồi-ghi không phân biệt được lease cũ với lease mới, và giá cũ lại thắng (xem kịch bản “Lease với token cố định” ở trên).
  • Reader không lấy được lease (có người khác đang nạp) thì chờ một chút rồi đọc lại, có giới hạn số lần thử.

Remote marker bổ sung cho lease khi đọc từ replica: writer đặt thêm một key đánh dấu (marker:product:123, TTL 1–2 giây, phủ thời gian lag dự kiến). Reader thấy marker thì biết replica có thể chưa mới: đọc từ primary hoặc chờ. Cái giá: logic phía app phức tạp, latency tăng trong lúc marker còn, và marker có thể bị cache đẩy ra khi đầy bộ nhớ.

Version: chỉ ghi nếu mới hơn ​

Lưu kèm version (lấy từ cột version của bảng) trong giá trị cache, và chỉ ghi khi version mới hơn version đang có. Điểm mấu chốt: khi ghi database, writer không xoá hẳn key mà để lại một bản đánh dấu chỉ chứa version mới (không có dữ liệu, TTL ngắn). Nếu xoá hẳn, cache trống, reader mang version cũ vẫn ghi được vì không còn gì để so sánh.

  • Reader gặp bản đánh dấu thì coi như miss, đọc database, rồi ghi kèm version vừa đọc.
  • Reader bị dừng giữa chừng, hoặc đọc phải replica cũ, mang version nhỏ hơn bản đánh dấu nên bị từ chối. Người dùng của reader đó vẫn nhận dữ liệu cũ một lần, nhưng cache không bị nhiễm.

Kiểm tra và ghi phải nguyên tử, bằng Lua script trên Redis:

lua
-- KEYS[1] = key, ARGV[1] = version, ARGV[2] = dữ liệu ('' = bản đánh dấu), ARGV[3] = TTL (giây)
local nv  = tonumber(ARGV[1])
local cur = tonumber(redis.call('HGET', KEYS[1], 'v'))
if cur then
  if cur > nv then return 0 end                                          -- đã có bản mới hơn
  if cur == nv and redis.call('HEXISTS', KEYS[1], 'data') == 1 then return 0 end
end
redis.call('HSET', KEYS[1], 'v', nv)
if ARGV[2] == '' then redis.call('HDEL', KEYS[1], 'data')                -- writer: bản đánh dấu
else redis.call('HSET', KEYS[1], 'data', ARGV[2]) end                   -- reader: dữ liệu
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1

Writer gọi cùng script với dữ liệu rỗng để ghi bản đánh dấu, nên bản đánh dấu cũng không bao giờ đè lên version mới hơn. Đơn giản hơn lease, nhưng cần bảng có cột version (Versioning). Bản đánh dấu bị đẩy ra khi cache đầy thì lại quay về trường hợp không có bảo vệ; TTL vẫn là lưới an toàn cuối.

So sánh ​

Cache-asideDelayed double deleteCDCLeaseLease + remote markerVersion
Reader bị dừng giữa chừng✗◐ nếu dừng ngắn hơn T✗✓✓✓
Replication lag✗◐ nếu lag ngắn hơn T◐ đọc binlog của replica✗✓✓
Ghi xong, xoá thất bại✗✗✓✗✗✗
Độ phức tạpThấpTrung bìnhCao (hạ tầng)Rất caoRất caoTrung bình

✓ giải quyết · ◐ giảm được một phần · ✗ không giải quyết. Ô nào cũng còn TTL làm lưới an toàn.

Chọn cách nào ​

  1. Bắt đầu với cache-aside + TTL. Đa số sản phẩm (mạng xã hội, tin tức, thương mại điện tử) chấp nhận được việc thấy dữ liệu cũ vài giây.
  2. Cần chắc hơn, nhất là khi nhiều service cùng ghi một bảng: xoá qua CDC, đọc binlog của replica.
  3. Có reader dễ bị dừng lâu (GC lớn) hoặc giá trị cache tốn công dựng: thêm version.
  4. Lease + remote marker chỉ đáng khi ở quy mô cực lớn, nơi 0,01% sai lệch là hàng triệu người dùng.

Ghi nhớ

  1. Xoá cache, không SET; ghi database trước, xoá cache sau.
  2. Ba lỗ hổng còn lại: reader bị dừng giữa chừng, replication lag, xoá cache thất bại.
  3. Delayed double delete: chọn p99 lag + p99 đọc < T < TTL.
  4. CDC giải quyết xoá thất bại; đọc binlog của replica thì giải quyết luôn replication lag.
  5. Lease cần token duy nhất; version check bằng Lua đơn giản hơn lease, nhưng writer phải để lại bản đánh dấu version thay vì xoá hẳn key.