Hình dung một tủ hồ sơ là nguồn sự thật, và cạnh nó có một tấm bảng trắng ghi tóm tắt để mọi người khỏi phải mở tủ mỗi lần. Bây giờ hai người cùng sửa một hồ sơ trong tủ, mỗi người xong lại quay ra ghi con số mới của mình lên bảng. Vấn đề: thứ tự họ ghi vào tủ và thứ tự họ ghi lên bảng là hai chuyện hoàn toàn tách rời — người sửa tủ sau cùng chưa chắc là người ghi bảng sau cùng. Kết quả là bảng trắng có thể hiển thị con số của người ghi-bảng-cuối, trong khi tủ giữ con số của người sửa-tủ-cuối, và hai thứ đó lệch nhau. Cách thoát duy nhất hoá ra không phải ghi cẩn thận hơn, mà là đừng ghi giá trị lên bảng cả — chỉ xoá dòng đó đi và để dòng chữ "hỏi lại trong tủ". Bài này đo bốn cách giữ cache khớp với cơ sở dữ liệu, và cả bốn đều có chỗ hỏng.

Bộ đệm có ích khi nó đúng. Ta đo bốn cách giữ nó đúng khi dữ liệu gốc thay đổi.

Bốn chiến lược, 8 luồng vừa đọc vừa ghi

Chiến lược Số lần bộ đệm lệch / 120 lần kiểm
Ghi đè bộ đệm bằng giá trị mới 103
Xoá bộ đệm trước khi cập nhật 0
Xoá bộ đệm sau khi cập nhật 3
Xoá hai lần (trước và sau 50 ms) 0

Dòng đầu là kết quả tệ nhất và cũng là chiến lược trực giác nhất.

Ghi đè bộ đệm là tệ nhất

103 trên 120 lần kiểm — bộ đệm nói một đằng, cơ sở dữ liệu nói một nẻo. Lý do là một tình huống đua rất khó tránh:

A ghi CSDL = 1
B ghi CSDL = 2
B ghi bộ đệm = 2
A ghi bộ đệm = 1      <- A cham hon, nhung ghi sau

Bộ đệm nói 1, cơ sở dữ liệu nói 2, và nó ở đó cho tới khi hết TTL. Thứ tự ghi vào cơ sở dữ liệu và thứ tự ghi vào bộ đệm là hai chuỗi độc lập; không có gì bắt chúng khớp nhau — đúng cái tủ với tấm bảng. Với hai hệ thống riêng biệt không có giao dịch chung, đây là vấn đề không giải được, chỉ giảm thiểu được.

Đó là lý do xoá tốt hơn ghi: xoá không có "giá trị nào thắng", nó chỉ nói "không biết, hỏi lại nguồn".

Nhưng bảng trên đổi khi có người đọc song song

Phép đo đầu tiên chỉ có luồng vừa đọc vừa ghi. Tôi đo lại với bốn luồng đọc liên tục tự nạp lại bộ đệm khi trượt — tức là mô hình thật.

xoá trước    lần 1  bộ đệm=623681  CSDL=104088   LỆCH
             lần 2  KHỚP     lần 3  KHỚP
xoá sau      3/3 KHỚP
xoá hai lần  3/3 KHỚP

Xoá trước khi cập nhật để lại dữ liệu cũ vĩnh viễn trong một trên ba lần chạy. Chuỗi sự kiện:

người ghi:  DEL bộ đệm
người đọc:  GET -> trượt
người đọc:  SELECT -> nhận giá trị CŨ (cập nhật chưa chạy)
người ghi:  UPDATE CSDL = mới
người đọc:  SET bộ đệm = CŨ

Bộ đệm giữ giá trị cũ, và không có lần xoá nào nữa để sửa. Nó ở đó cho tới khi hết TTL — có thể hàng giờ. Phép đo đầu tiên không bắt được điều này vì nó không có người đọc chen vào đúng khe đó. Đây là lý do phải đo với mô hình đọc thật, không chỉ với luồng ghi.

Kết luận: xoá sau khi cập nhật

db.execute("update sp set ... where id = %s", id)
r.delete(f"sp:{id}")

Thứ tự này thu hẹp khe hở xuống mức nhỏ nhất: giữa UPDATE và DEL, người đọc thấy giá trị cũ trong bộ đệm — nhưng lần xoá sẽ tới và sửa nó. Ba lần lệch đo được ở bảng đầu chính là khe đó, và chúng tự khỏi ở lần đọc tiếp theo. Đó là khác biệt cốt lõi so với "xoá trước": lệch tạm thời so với lệch vĩnh viễn.

Xoá hai lần: an toàn hơn, chậm 5 lần

r.delete(key)
db.execute("update ...")
time.sleep(0.05)          # hoac hen gio o hang doi
r.delete(key)

0 lệch trong cả hai phép đo, nhưng tổng thời gian 1,0 giây so với 0,2 giây — chậm 5 lần vì mỗi lần ghi phải chờ. Trong thực tế, lần xoá thứ hai không nên chặn: đẩy nó vào hàng đợi với độ trễ. Lúc đó chi phí là một bản tin thêm, không phải 50 ms mỗi lần ghi. Dùng nó khi một lần lệch cũng không chấp nhận được — số dư tài khoản, tồn kho. Với dữ liệu hiển thị thì "xoá sau" là đủ.

Cách thứ năm: để cơ sở dữ liệu tự báo

Cả bốn cách trên đều yêu cầu mã ứng dụng nhớ xoá bộ đệm. Một chỗ quên là một khoá lệch mãi. Cách bền hơn là đọc nhật ký thay đổi của cơ sở dữ liệu — logical replication của PostgreSQL, binlog của MySQL — rồi xoá khoá tương ứng. Ưu điểm:

  • Không phụ thuộc vào việc lập trình viên nhớ.
  • Bắt được cả thay đổi từ script chạy tay, từ migration, từ công cụ quản trị.
  • Thứ tự đúng theo thứ tự commit thật.

Nhược điểm: thêm một thành phần phải vận hành, và độ trễ vài chục tới vài trăm mili giây. Đây là cách các hệ thống lớn thực sự làm, và nó là lựa chọn đúng khi số chỗ ghi dữ liệu nhiều hơn số người nhớ được quy ước.

Điều không có cách nào tránh

Không có cách nào làm bộ đệm và cơ sở dữ liệu luôn khớp mà không có giao dịch phân tán — và giao dịch phân tán đắt hơn nhiều so với việc chấp nhận vài trăm mili giây lệch. Nên câu hỏi thiết kế đúng không phải "làm sao luôn khớp" mà là "lệch bao lâu thì chấp nhận được", rồi chọn chiến lược rẻ nhất đạt mức đó. Và bất kể chọn gì: luôn đặt TTL. TTL là thứ giới hạn thiệt hại của mọi lần xoá bị bỏ lỡ, và mọi cách ở trên đều có trường hợp bỏ lỡ.

Mẫu số chung: hai bản sao của một sự thật, không giao dịch chung

Cái ta vừa đo có một cái tên trong thiết kế hệ thống: bài toán ghi kép (dual write). Bất cứ khi nào cùng một sự thật sống ở hai kho — cache và CSDL, CSDL và chỉ mục tìm kiếm, dịch vụ A và dịch vụ B — mà không có một giao dịch bao trùm cả hai, thì không tồn tại cách nào giữ chúng luôn khớp; bạn chỉ chọn được lệch tạm thời hay lệch vĩnh viễn, và chọn được khe lệch rộng bao nhiêu. Và bài học "xoá tốt hơn ghi" là chung: một thao tác vô hiệu hoá (invalidate) không mang giá trị nên không có cuộc đua "ai thắng"; một thao tác ghi giá trị thì có.

Trong thế giới Java, đây đúng là lý do Spring Cache khuyến nghị @CacheEvict hơn @CachePut: evict chỉ nói "quên đi, đọc lại", còn put ghi một giá trị có thể đến sai thứ tự — chính xác 103/120 ở trên. Và ở quy mô microservice, "ghi kép vào DB rồi publish sự kiện" là cái bẫy kinh điển mà mẫu outbox (ghi sự kiện vào cùng transaction với dữ liệu, rồi để một tiến trình đọc nó ra) sinh ra để tránh — nó biến hai lần ghi thành một.

Trong Go, cùng lời khuyên: khi giữ một cache tại chỗ trước một CSDL, hãy vô hiệu hoá theo sự kiện ghi thay vì ghi đè, và nếu cần chắc chắn thì dùng outbox/CDC chứ đừng tin vào việc mọi đường ghi đều nhớ xoá.

Còn ở cơ sở dữ liệu, đây là toàn bộ họ vấn đề "eventual consistency": một materialized view cũ cho tới lần refresh, một read replica trả dữ liệu chậm hơn primary vài chục mili giây, một chỉ mục Elasticsearch lệch với bảng nguồn. Lời giải bền vững ở mọi tầng đều giống nhau: lấy nhật ký thay đổi của chính CSDL (CDC — Debezium, logical replication, binlog) làm nguồn phát tán duy nhất, thay vì để mỗi đường ghi tự nhớ cập nhật bản sao.

Mẫu số chung: khi một sự thật tồn tại ở hai nơi mà không có giao dịch chung, tính nhất quán tuyệt đối là bất khả — nên đừng hỏi "làm sao luôn khớp", hãy hỏi "lệch bao lâu thì chịu được", ưu tiên vô hiệu hoá hơn ghi đè, và nếu số đường ghi vượt số người nhớ quy ước thì để chính change-log của nguồn phát đi thông báo. Với Redis là DEL sau UPDATE cộng một TTL; với hệ lớn là CDC. Không tầng nào thoát được câu hỏi "lệch bao lâu là đủ tốt".

Muốn tự tìm khoá bộ đệm đã lệch:

redis-cli --scan --pattern 'sp:*' --count 500 | head -200 | while read k; do
  id="${k#sp:}"
  bo_dem=$(redis-cli get "$k")
  csdl=$(psql -h <host> -U <user> -tAc "select mo_ta from sp where id=$id" | xargs)
  [ "$bo_dem" != "$csdl" ] && echo "LECH $k: bo dem='$bo_dem' csdl='$csdl'"
done

Chạy nó một lần trên hệ thống thật thường cho kết quả bất ngờ. Và nếu bạn tìm thấy khoá lệch mà không có TTL, đó là dữ liệu sai sẽ không bao giờ tự sửa.

Phần sau: theo dõi Redis — những chỉ số đáng cảnh báo và những chỉ số gây hiểu nhầm.