Lịch sử System Design · Phần I · về mục lục · chương trước

Chương 4. Bộ nhớ đệm và CDN

tỉ lệ trúng, dồn tải, vô hiệu hoá và lưu đệm tại biên

Chương này phân tích việc đặt bộ nhớ đệm giữa ứng dụng và cơ sở dữ liệu, rồi giữa người dùng và máy gốc. Tình huống nghiên cứu là đêm khuyến mãi của Chợ Phố, khi một bộ nhớ đệm mới thêm vào làm cơ sở dữ liệu quá tải theo chu kỳ và hiển thị giá cũ. Chương trình bày các mẫu đọc ghi qua bộ nhớ đệm, cách định lượng tỉ lệ trúng, cơ chế dồn tải và cách chống, việc vô hiệu hoá khi dữ liệu đổi, và CDN.

Thời gian đọc khoảng 25 phút Chương sau Chương 5. Mở rộng cơ sở dữ liệu Cập nhật 27/10/2026

Tình huống: giá cũ và cơn tăng tải lúc nửa đêm

Cuối chương 3, Chợ Phố chạy hai máy chủ (server) ứng dụng phía sau một bộ cân bằng tải (load balancer). Phía sau chúng vẫn là một cơ sở dữ liệu (database) PostgreSQL duy nhất. Vào giờ cao điểm, CPU của cơ sở dữ liệu ở mức 85%, và phần lớn tải là các truy vấn (query) đọc lặp lại cùng dữ liệu sản phẩm.

Khoảng tháng thứ 16, khi trang web (site) đã có 150.000 người dùng mỗi ngày, nhóm kỹ thuật do An phụ trách thêm một bộ nhớ đệm (cache) chạy trên một instance Redis riêng. Instance Redis đang làm kho phiên ở chương 3 dùng chính sách noeviction để không bao giờ xoá phiên, trong khi bộ nhớ đệm cần được loại bỏ khoá (key) khi đầy bộ nhớ. Khoá là tên dùng để tra một mục trong bộ nhớ đệm, ví dụ product:v1:42 cho sản phẩm số 42. Dùng chung một instance thì chính sách loại bỏ của bộ nhớ đệm có thể xoá cả phiên đăng nhập. Dữ liệu sản phẩm được đọc theo mẫu nạp khi cần (cache-aside), mỗi khoá có cùng một thời gian sống cố định. CPU của cơ sở dữ liệu giảm xuống còn khoảng một phần tư, và độ trễ (latency) của trang sản phẩm giảm rõ rệt. Ba tuần sau, đêm mở đợt khuyến mãi giữa năm diễn ra như sau.

23:50
Nhóm triển khai (deploy) phiên bản hiển thị nhãn khuyến mãi. Cấu trúc dữ liệu sản phẩm thay đổi, nên tiền tố khoá chuyển từ product:v1: sang product:v2:.
00:00
Đợt khuyến mãi bắt đầu. Tác vụ định kỳ (cron job) đổi giá 12.000 sản phẩm bằng câu lệnh SQL chạy thẳng trên cơ sở dữ liệu.
00:01
CPU cơ sở dữ liệu lên 98%. Trang danh mục khuyến mãi phản hồi trung bình sau 3,8 giây.
00:04
Bộ phận hỗ trợ báo khách thấy giá cũ trên trang sản phẩm, trong khi trang thanh toán tính đúng giá mới.
00:07
CPU tự giảm. Không ai thay đổi gì.
00:10
CPU tăng vọt lần thứ hai, thấp hơn lần đầu. Lúc 00:20 lặp lại lần thứ ba, thấp hơn nữa.
Chương này trả lời ba câu hỏi. Vì sao một bộ nhớ đệm vốn để giảm tải lại làm cơ sở dữ liệu quá tải, và vì sao quá tải lặp lại theo chu kỳ? Vì sao khách vẫn thấy giá cũ sau khi giá đã đổi? Phần dữ liệu nào nên được lưu đệm ở xa hơn, gần khách hàng hơn?

Nguyên lý: lưu kết quả đọc gần nơi dùng

Bộ nhớ đệm là một kho lưu tạm, nhanh hơn kho gốc, giữ lại kết quả của những lần đọc gần đây để lần đọc sau khỏi phải hỏi kho gốc. Một lần đọc tìm thấy dữ liệu trong bộ nhớ đệm gọi là trúng (hit). Lần đọc không tìm thấy gọi là trượt (miss) và phải hỏi kho gốc. Tỉ lệ trúng (hit ratio) là số lần trúng chia cho tổng số lần đọc.

Bộ nhớ đệm có ích khi thoả hai điều kiện. Điều kiện thứ nhất là cùng một dữ liệu được đọc nhiều lần trước khi nó thay đổi. Điều kiện thứ hai là truy cập phân bố lệch, tức một phần nhỏ dữ liệu nhận phần lớn lượt đọc. Danh mục 48.000 sản phẩm của Chợ Phố thoả cả hai: giá và mô tả sản phẩm đổi vài lần mỗi tháng, trong khi các sản phẩm đang khuyến mãi được xem liên tục.

Đổi lại, mọi bộ nhớ đệm đều có thể trả về dữ liệu cũ. Từ khi bản lưu được tạo đến khi nó bị xoá, kho gốc có thể đã thay đổi. Thiết kế bộ nhớ đệm vì vậy luôn gồm hai quyết định: lưu cái gì ở đâu, và bản lưu bị loại bỏ khi nào. Mục tiếp theo xét câu hỏi thứ nhất qua hai vị trí đặt bộ nhớ đệm.

Bộ nhớ đệm trong tiến trình và bộ nhớ đệm dùng chung

Bộ nhớ đệm trong tiến trình (local cache) nằm ngay trong bộ nhớ của tiến trình (process) ứng dụng. Với Tomcat, đó là một cấu trúc dữ liệu trong heap của JVM, thường dùng thư viện Caffeine. Một lần trúng chỉ là một lần tra bảng băm trong bộ nhớ, không qua mạng. Đây là tầng đọc nhanh nhất mà ứng dụng có thể có.

Khi Chợ Phố có hai máy ứng dụng, tầng này bộc lộ ba hạn chế. Mỗi máy giữ một bản riêng, nên hai máy có thể trả hai giá khác nhau cho cùng một sản phẩm, tuỳ yêu cầu (request) của khách rơi vào máy nào. Vô hiệu hoá bộ nhớ đệm (cache invalidation) là việc xoá hoặc thay bản lưu khi dữ liệu gốc thay đổi. Với tầng này, muốn vô hiệu hoá một khoá, ứng dụng phải báo cho mọi máy, thường qua cơ chế phát tin (chương 11). Ngoài ra, một máy mới khởi động hoặc vừa được thêm vào bắt đầu với bộ nhớ đệm trống.

Kéo ngang để xem hết hình

HAI TẦNG BỘ NHỚ ĐỆM SAU KHI CÓ HAI MÁY ỨNG DỤNG Máy ứng dụng 1 tầng 1, không qua mạng Caffeine trong heap JVM product:42 = giá trước khi đổi Máy ứng dụng 2 tầng 1, không qua mạng Caffeine trong heap JVM product:42 = giá mới Redis tầng 2, dùng chung PostgreSQL nguồn dữ liệu gốc một lượt đi về qua mạng chỉ khi trượt cả hai tầng
Hình 4.1. Tầng 1 nằm trong từng máy nên mỗi máy giữ bản riêng. Khung đỏ là bản chưa được vô hiệu hoá: cùng một sản phẩm, khách thấy giá nào tuỳ yêu cầu rơi vào máy nào.

Bộ nhớ đệm dùng chung (shared cache) là một dịch vụ (service) riêng mà mọi máy ứng dụng cùng đọc ghi. Mỗi lần đọc tốn một lượt đi về qua mạng, chậm hơn tầng trong tiến trình. Bù lại, mọi máy thấy cùng một bản, vô hiệu hoá một lần là đủ, và bộ nhớ đệm không mất khi một máy ứng dụng khởi động lại. Hai phần mềm phổ biến nhất cho vai trò này là Redis và Memcached. Cả hai giữ dữ liệu trong bộ nhớ trong (RAM). Redis thực thi lệnh chủ yếu trên một luồng (thread) chính, còn Memcached phục vụ bằng nhiều luồng.

Tiêu chíRedisMemcached
Kiểu giá trịChuỗi, hash, list, set, sorted set và các kiểu khácChuỗi byte; mặc định tối đa 1 MB mỗi mục (-I)
Thực thi lệnhChủ yếu trên một luồng chính; bản mới có luồng phụ cho vào ra mạng (io-threads)Đa luồng, mặc định 4 luồng (-t)
Lưu bềnTuỳ chọn RDB (ảnh chụp) và AOF (nhật ký lệnh ghi)Mặc định không ghi ra ổ đĩa
Khi đầy bộ nhớTheo maxmemory-policy; mặc định noeviction từ chối lệnh ghiLoại bỏ mục ít dùng gần đây (LRU)

Nhóm chọn Redis vì đã vận hành một instance Redis cho kho phiên, nên cách cấu hình và giám sát dùng lại được. Với nhu cầu của Chợ Phố, Memcached cũng đáp ứng được. Giao thức của Memcached có lệnh add, chỉ ghi khi khoá chưa tồn tại, tương đương SET với tuỳ chọn NX của Redis. Thực thi lệnh trên một luồng chính cũng có hệ quả hữu ích: mỗi lệnh là nguyên tử, không lệnh nào xen vào giữa lệnh khác. Câu hỏi tiếp theo là ứng dụng đọc ghi qua bộ nhớ đệm theo trình tự nào.

Đi sâu: Redis hết bộ nhớ thì làm gì

Tham số maxmemory mặc định bằng 0, nghĩa là Redis không tự giới hạn bộ nhớ trên hệ 64-bit. Khi đặt giới hạn, maxmemory-policy quyết định hành vi lúc đầy. Giá trị mặc định trong redis.conf là noeviction: các lệnh đọc vẫn chạy, còn lệnh ghi dữ liệu mới nhận lỗi. Với một Redis chỉ làm bộ nhớ đệm, hành vi đó biến mọi lần nạp lại thành lỗi, nên chính sách phù hợp hơn là allkeys-lru.

Redis không duy trì danh sách LRU chính xác. Mỗi lần cần loại một khoá, Redis lấy mẫu vài khoá và loại khoá lâu chưa dùng nhất trong mẫu. Các chính sách volatile-* chỉ chọn trong những khoá có thời gian sống, nên khoá không có hạn sẽ không bao giờ bị loại.

Kích thước cần thiết ước lượng được từ dữ liệu: 48.000 × 4 KB = 192 MB cho các khoá sản phẩm, với giả định mỗi bản JSON khoảng 4 KB. Giới hạn trong cấu hình dưới đây để dư nhiều lần cho các khoá trang danh mục và cho tăng trưởng. Chỉ số evicted_keys trong INFO stats cho biết giới hạn đã chật hay chưa.

redis.conf# redis.conf cho một Redis chỉ làm bộ nhớ đệm
# đơn vị: 1g = 1.000.000.000 byte, còn 1gb = 1024 × 1024 × 1024 byte
maxmemory 1g
# mặc định là noeviction: đầy bộ nhớ thì lệnh ghi bị từ chối
maxmemory-policy allkeys-lru
# LRU xấp xỉ: số khoá lấy mẫu mỗi lần chọn khoá để loại
maxmemory-samples 5

Bốn cách đọc ghi qua bộ nhớ đệm

Bốn mẫu dưới đây khác nhau ở một điểm: thành phần nào chịu trách nhiệm nạp dữ liệu vào bộ nhớ đệm và ghi dữ liệu xuống kho gốc.

Nạp khi cần là mẫu trong đó ứng dụng tự hỏi bộ nhớ đệm trước, trượt thì đọc cơ sở dữ liệu rồi ghi kết quả vào bộ nhớ đệm. Tài liệu của AWS ElastiCache gọi chiến lược này là lazy loading. Thao tác ghi đi thẳng vào cơ sở dữ liệu. Bộ nhớ đệm chỉ được cập nhật khi mã ghi chủ động xoá hoặc ghi đè khoá.

Kéo ngang để xem hết hình

Ứng dụng Redis PostgreSQL ĐỌC 1. GET product:v2:42 2. nil: trượt 3. SELECT ... WHERE id = 42 4. dòng dữ liệu sản phẩm 5. SET product:v2:42 ... EX 600 GHI 6. UPDATE giá, COMMIT 7. DEL product:v2:42
Hình 4.2. Ứng dụng là thành phần duy nhất nói chuyện với cả hai kho. Redis không biết PostgreSQL tồn tại, nên bước 7 chỉ xảy ra khi mã ghi dữ liệu chủ động gọi nó.
java// Mẫu nạp khi cần với TTL cố định: phiên bản đầu tiên của Chợ Phố
// redis là client JedisPooled của Jedis (từ Jedis 7.2: RedisClient)
Product findProduct(long id) {
    String key = "product:v2:" + id;
    String json = redis.get(key);
    if (json != null) {
        return Product.fromJson(json);                 // trúng
    }
    Product p = productRepo.findById(id);              // trượt: một truy vấn SELECT
    redis.setex(key, 600, p.toJson());                 // mọi khoá sống đúng 600 giây
    return p;
}

Đọc xuyên (read-through) giống nạp khi cần về kết quả, nhưng việc nạp do chính bộ nhớ đệm đảm nhận. Ứng dụng chỉ gọi bộ nhớ đệm và khai báo sẵn một hàm nạp. Caffeine hoạt động theo kiểu này. Với Redis mã nguồn mở, đọc xuyên thường do một thư viện phía ứng dụng bọc quanh Redis.

Ghi xuyên (write-through) là mẫu trong đó mỗi lần ghi cơ sở dữ liệu cũng ghi giá trị mới vào bộ nhớ đệm, đồng bộ, trong cùng thao tác. Dữ liệu vừa ghi luôn có sẵn trong bộ nhớ đệm. Cái giá là mỗi lần ghi chậm thêm một bước, và bộ nhớ đệm chứa cả dữ liệu ghi xong nhưng không ai đọc.

Ghi sau (write-behind, còn gọi write-back) là mẫu trong đó ứng dụng chỉ ghi vào bộ nhớ đệm, và bộ nhớ đệm ghi xuống cơ sở dữ liệu sau đó theo lô, bất đồng bộ. Độ trễ ghi thấp nhất trong bốn mẫu. Đổi lại, nếu máy bộ nhớ đệm hỏng trước khi kịp ghi xuống, các thay đổi đó mất.

MẫuAi nạp và ai ghiRủi ro chính
Nạp khi cầnỨng dụng nạp khi trượt; ứng dụng ghi cơ sở dữ liệuDữ liệu cũ nếu mã ghi không vô hiệu hoá khoá
Đọc xuyênBộ nhớ đệm gọi hàm nạp; ứng dụng ghi cơ sở dữ liệuNhư nạp khi cần
Ghi xuyênMỗi lần ghi cập nhật cả hai nơi, đồng bộGhi chậm hơn; lưu cả dữ liệu không ai đọc
Ghi sauỨng dụng ghi bộ nhớ đệm; ghi xuống cơ sở dữ liệu sauMất thay đổi khi bộ nhớ đệm hỏng trước lúc ghi xuống

Nhóm chọn nạp khi cần vì hai lý do. Mẫu này không đòi thay đổi đường ghi, vốn gồm cả đơn hàng và thanh toán. Khi Redis ngừng hoạt động, ứng dụng vẫn đọc được cơ sở dữ liệu, chỉ chậm hơn. Ghi sau bị loại ngay, vì một thay đổi giá hay một đơn hàng không được phép mất. Mục tiếp theo đo xem lựa chọn này giảm được bao nhiêu tải.

Định lượng: tỉ lệ trúng và tải còn lại trên cơ sở dữ liệu

Thời gian sống (time to live, TTL) là khoảng thời gian một khoá tồn tại trong bộ nhớ đệm trước khi tự hết hạn. Lệnh SET với tuỳ chọn EX đặt TTL tính bằng giây. Trong phiên bản đầu, mọi khoá sản phẩm có TTL 600 giây. Với TTL cố định, mỗi khoá được nạp nhiều nhất một lần trong mỗi chu kỳ sống, nên số lần trượt có một cận trên tính được từ số khoá.

  1. Tải đọc sản phẩm trước khi có bộ nhớ đệm. Ba phần tư số truy vấn giờ cao điểm là đọc dữ liệu sản phẩm: 3.600 × 0,75 = 2.700 truy vấn/giây.
  2. Cận trên của số lần trượt. Nếu cả danh mục đều được xem trong mỗi chu kỳ, số lần trượt là 48.000 ÷ 600 = 80 lần trượt/giây.
  3. Tỉ lệ trúng ước lượng. 1 − 80 ÷ 2.700 ≈ 97,0%.
  4. Tỉ lệ trúng đo được. Từ bộ đếm của Redis sau một giờ cao điểm: 9.432.000 ÷ (9.432.000 + 288.000) ≈ 97,0%.
  5. Tải còn lại trên cơ sở dữ liệu. Truy vấn khác cộng số lần trượt: 900 + 80 = 980 truy vấn/giây. Giả định CPU tỉ lệ với số truy vấn, CPU còn 85% × 980 ÷ 3.600 ≈ 23%.
shell$ redis-cli CONFIG RESETSTAT        # 20:00, đặt lại bộ đếm thống kê
OK
$ redis-cli INFO stats | grep -E 'keyspace_(hits|misses)|expired_keys|evicted_keys'
keyspace_hits:9432000                 # 21:00, sau một giờ cao điểm
keyspace_misses:288000
expired_keys:288000
evicted_keys:0

Hai chỉ số keyspace_hits và keyspace_misses đếm số lần tra khoá thành công và thất bại kể từ lần đặt lại. Tài liệu của Redis tính tỉ lệ trúng theo đúng công thức ở bước 4. Chỉ số evicted_keys bằng 0 cho thấy chưa khoá nào bị loại vì thiếu bộ nhớ, nên mọi lần trượt đều do hết hạn.

Giả định CPU tỉ lệ với số truy vấn là một xấp xỉ, vì truy vấn trang danh mục đắt hơn nhiều so với truy vấn một sản phẩm. Kết luận vẫn đứng vững: tỉ lệ trúng cao làm tải đọc sản phẩm gần như biến mất khỏi cơ sở dữ liệu. Phần còn lại của chương xét điều xảy ra khi tỉ lệ đó sụp đổ trong vài giây.

Chẩn đoán sự cố lúc 00:00

Dấu hiệu quan trọng nhất trên đồ thị là chu kỳ. CPU tăng lúc 00:00, 00:10 và 00:20, tức cách nhau đúng bằng TTL. Một tác vụ đổi giá chỉ chạy một lần, nên bản thân nó không giải thích được các đỉnh lặp lại.

Kéo ngang để xem hết hình

CPU CƠ SỞ DỮ LIỆU QUANH NỬA ĐÊM, ĐO THEO PHÚT 0% 25% 50% 75% 100% 23:45 23:50 00:00 00:10 00:20 00:30 TTL cố định: đỉnh lặp lại mỗi chu kỳ TTL đêm sau khi sửa: TTL có jitter và gộp yêu cầu triển khai, khoá v2
Hình 4.3. Đường đỏ là đêm sự cố, đường màu nhấn là đêm sau khi sửa. Trên đường màu nhấn, lần triển khai và lúc khuyến mãi bắt đầu chỉ làm CPU nhích lên một bậc rồi đi ngang.

Để kiểm giả thuyết, An xem TTL còn lại của một số khoá ngay trước đỉnh thứ hai. Lệnh TTL trả số giây còn lại của một khoá. Giá trị âm có nghĩa khoá không tồn tại hoặc không có hạn.

shell$ date +%T
00:09:20
$ redis-cli TTL category:v2:khuyen-mai:1
(integer) 41
$ redis-cli TTL category:v2:khuyen-mai:7
(integer) 40
$ redis-cli TTL product:v2:1042
(integer) 43
$ redis-cli TTL product:v2:877
(integer) 39

Các khoá thuộc nhiều trang và nhiều sản phẩm khác nhau, nhưng cùng còn khoảng bốn mươi giây. Chúng sẽ hết hạn gần như cùng lúc vào 00:10, và đỉnh thứ hai xuất hiện đúng như dự đoán. Nguồn gốc của sự đồng bộ này là lần triển khai lúc 23:50.

Phiên bản mới đọc khoá có tiền tố v2, nên lúc triển khai bộ nhớ đệm coi như trống. Các khoá được đọc nhiều nhất, như trang danh mục khuyến mãi và sản phẩm nổi bật, được nạp lại trong vài giây đầu. Vì TTL cố định, chúng cùng hết hạn đúng một chu kỳ sau, vào 00:00. Đó cũng là lúc đợt khuyến mãi bắt đầu và lượng truy cập tăng mạnh.

Lúc 23:50 lượng truy cập còn thấp, nên lần nạp đồng loạt đầu tiên chỉ tạo một gợn nhỏ trên đồ thị. Lúc 00:00 cùng sự đồng bộ đó gặp lượng truy cập cao nhất trong đêm. Mục tiếp theo giải thích vì sao một lần hết hạn đồng loạt có thể đẩy CPU lên gần trần.

Hiện tượng dồn tải khi khoá hết hạn

Dồn tải (cache stampede) là hiện tượng nhiều yêu cầu cùng trượt một khoá, và mỗi yêu cầu tự truy vấn cơ sở dữ liệu để nạp lại cùng một giá trị. Trong mẫu nạp khi cần, khoá còn trống suốt khoảng thời gian từ lúc trượt đầu tiên đến lúc giá trị mới được ghi vào. Mọi yêu cầu đến trong khoảng đó đều trượt.

Số truy vấn sinh ra cho một khoá vì thế bằng một cộng số yêu cầu đến trong lúc nạp. Gọi λ là số yêu cầu mỗi giây vào khoá đó và W là thời gian một truy vấn nạp hoàn tất. Số truy vấn cho một lần hết hạn xấp xỉ D = 1 + λ × W.

W không cố định. Theo mô hình hàng đợi (queue) ở chương 0, thời gian hoàn tất bằng thời gian phục vụ S chia cho 1 − ρ. Trong đó ρ là mức sử dụng (utilization) của cơ sở dữ liệu. Khi ρ tăng, W tăng, D tăng, và chính các truy vấn thừa lại đẩy ρ lên cao hơn. Đây là một vòng phản hồi dương.

  1. Khoá đắt nhất. Trang danh mục khuyến mãi có 40 khoá, mỗi khoá nhận 80 yêu cầu mỗi giây lúc 00:00. Truy vấn dựng một trang danh mục tốn 0,12 giây CPU.
  2. Lúc cơ sở dữ liệu còn nhàn. W = 0,12 ÷ (1 − 0,23) ≈ 0,156 giây, nên D = 1 + 80 × 0,156 ≈ 13,5 truy vấn cho mỗi khoá.
  3. Tổng cho các khoá danh mục. 40 × 13,5 = 540 truy vấn, tốn 540 × 0,12 = 64,8 giây CPU, so với 40 × 0,12 = 4,8 giây CPU nếu mỗi khoá chỉ nạp một lần.
  4. Khi vòng phản hồi đã chạy. Ở mức sử dụng 0,95, W = 0,12 ÷ (1 − 0,95) = 2,4 giây và D = 1 + 80 × 2,4 = 193 truy vấn cho mỗi khoá.

Kéo ngang để xem hết hình

KHÔNG GỘP: MỖI YÊU CẦU TRƯỢT ĐỀU TRUY VẤN CÓ GỘP: MỘT TRUY VẤN, CÁC YÊU CẦU KHÁC CHỜ yêu cầu 1 yêu cầu 1 yêu cầu 2 yêu cầu 2 yêu cầu 3 yêu cầu 3 yêu cầu 4 yêu cầu 4 yêu cầu 5 yêu cầu 5 PostgreSQL nhận cùng một truy vấn nhiều lần gộp theo khoá yêu cầu 1 nạp, các yêu cầu sau chờ kết quả PostgreSQL một truy vấn
Hình 4.4. Bên trái, số truy vấn bằng số yêu cầu đến trong lúc khoá đang trống. Bên phải, nét đứt là các yêu cầu chờ kết quả của yêu cầu đầu tiên, không chạm tới cơ sở dữ liệu.

Hàng nghìn khoá sản phẩm hết hạn cùng đợt góp thêm tải theo cùng cơ chế, và tác vụ đổi giá cộng thêm tải ghi. Cơ sở dữ liệu chỉ thoát khỏi trạng thái nghẽn khi các khoá đắt đã được nạp lại xong. Vì các khoá này được nạp lại vào những thời điểm rải rác hơn lúc 23:50, mỗi đỉnh sau thấp hơn đỉnh trước.

Hai nguyên nhân cần xử lý vì thế là hai yếu tố trong công thức. Sự đồng bộ của thời điểm hết hạn làm nhiều khoá trượt cùng lúc. Thừa số λ × W làm mỗi khoá trượt sinh ra nhiều truy vấn.

Ba biện pháp chống dồn tải

Biện pháp thứ nhất là thêm độ lệch ngẫu nhiên (jitter) vào TTL. Thay vì một TTL cố định, mỗi khoá nhận TTL ngẫu nhiên trong khoảng cộng trừ 10%. Các khoá nạp cùng một giây sẽ hết hạn rải đều trong một khoảng rộng hơn nhiều.

  1. Độ rộng một phía. 600 × 0,1 = 60 giây.
  2. Khoảng cách trung bình giữa hai lần hết hạn của khoá danh mục. 120 ÷ 40 = 3 giây, đủ để mỗi khoá được nạp xong trước khi khoá tiếp theo hết hạn.

Biện pháp thứ hai là gộp yêu cầu (request coalescing), còn gọi single flight. Khi một khoá trượt, chỉ yêu cầu đầu tiên truy vấn cơ sở dữ liệu. Các yêu cầu đến sau cho cùng khoá chờ kết quả của yêu cầu đó. Đoạn mã dưới đây làm việc này trong phạm vi một JVM bằng một bảng các lần nạp đang chạy.

javaprivate final ConcurrentHashMap<String, CompletableFuture<String>> inflight = new ConcurrentHashMap<>();

// TTL có jitter: 600 giây cộng trừ 10%, để các khoá nạp cùng lúc không hết hạn cùng lúc
long ttlWithJitter() {
    return Math.round(600 * ThreadLocalRandom.current().nextDouble(0.9, 1.1));
}

// Gộp yêu cầu trùng trong một JVM: mỗi khoá chỉ có một luồng truy vấn cơ sở dữ liệu
String loadOnce(String key, Supplier<String> fromDb) {
    String hit = redis.get(key);
    if (hit != null) return hit;
    CompletableFuture<String> mine = new CompletableFuture<>();
    CompletableFuture<String> running = inflight.putIfAbsent(key, mine);
    if (running != null) return running.join();       // đã có luồng khác đang nạp: chờ kết quả
    try {
        String json = fromDb.get();                     // truy vấn duy nhất cho khoá này
        redis.setex(key, ttlWithJitter(), json);
        mine.complete(json);
        return json;
    } catch (RuntimeException e) {
        mine.completeExceptionally(e);                  // luồng đang chờ nhận CompletionException bọc lỗi này
        throw e;
    } finally {
        inflight.remove(key, mine);
    }
}

Phương thức putIfAbsent của ConcurrentHashMap là nguyên tử, nên với mỗi khoá chỉ một luồng đặt được phần tử của mình vào bảng. Thư viện Caffeine cung cấp sẵn hành vi tương tự: hàm nạp truyền cho get được gọi nhiều nhất một lần cho mỗi khoá. Gộp trong từng JVM giới hạn số truy vấn cho một khoá bằng số máy ứng dụng. Với các khoá danh mục, con số trở thành 40 × 2 = 80 truy vấn.

Biện pháp thứ ba là khoá phân tán ngắn (distributed lock), để gộp yêu cầu giữa các máy. Máy nào đặt được khoá phân tán thì nạp, máy khác chờ một lúc rồi đọc lại khoá dữ liệu. Biện pháp này thêm một lượt đi về Redis cho mỗi lần trượt. Nhóm chỉ áp dụng nó cho các khoá danh mục, nơi một truy vấn đủ đắt để đáng đổi.

redis-cli# Khoá phân tán ngắn quanh việc nạp lại một khoá đắt (redis-cli minh hoạ)
SET lock:category:v2:khuyen-mai:1 7f3c9a NX PX 3000
# "OK": máy này được nạp. (nil): máy khác đang nạp, đợi 50 ms rồi đọc lại khoá dữ liệu.

# Giải phóng: chỉ xoá khi giá trị còn là token của mình (Redis trước 8.4)
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:category:v2:khuyen-mai:1 7f3c9a

Giá trị ngẫu nhiên trong khoá phân tán đảm bảo một máy không xoá nhầm khoá phân tán của máy khác. Trường hợp đó xảy ra khi khoá phân tán đã hết hạn và bị máy khác lấy. Thời hạn PX giới hạn thời gian chờ nếu máy giữ khoá phân tán ngừng hoạt động giữa chừng.

Đi sâu: làm mới sớm và giới hạn của khoá phân tán

Làm mới sớm (early refresh) xử lý dồn tải từ gốc: khoá được nạp lại trước khi hết hạn, trong khi các yêu cầu khác vẫn đọc giá trị hiện có. Cách đơn giản là khi TTL còn lại dưới một ngưỡng, một yêu cầu đặt khoá phân tán rồi nạp lại ở nền. Vattani, Chierichetti và Lowenstein (PVLDB, 2015) đề xuất cách làm mới sớm theo xác suất. Xác suất một yêu cầu tự làm mới tăng dần khi khoá gần hết hạn và khi thời gian nạp lại lớn, nên không cần khoá phân tán.

Khoá phân tán kiểu này không an toàn tuyệt đối. Với một Redis đơn lẻ không bật lưu bền, Redis khởi động lại là mất mọi khoá phân tán đang giữ. Với Redis có bản sao (replica), máy bản sao có thể được nâng lên thay máy chính trước khi khoá phân tán kịp sao chép sang. Trong cả hai trường hợp, hai máy có thể cùng giữ một khoá phân tán. Với việc nạp lại bộ nhớ đệm, hậu quả chỉ là thêm một truy vấn thừa, nên mức an toàn này chấp nhận được. Với việc bảo vệ dữ liệu, như trừ tồn kho, khoá phân tán kiểu này không đủ.

Từ Redis 8.4, lệnh DELEX với điều kiện IFEQ thay được đoạn script giải phóng ở trên.

Ba biện pháp xử lý triệu chứng thứ nhất của đêm khuyến mãi. Triệu chứng thứ hai, khách thấy giá cũ, có nguyên nhân khác hẳn.

Giá cũ: vô hiệu hoá khi dữ liệu đổi

Mẫu nạp khi cần không tự vô hiệu hoá bộ nhớ đệm, vì Redis không biết cơ sở dữ liệu đã đổi. Nếu mã ghi không xoá khoá, bản lưu chỉ mất đi khi hết TTL.

Đêm khuyến mãi, tác vụ đổi giá cập nhật bảng products bằng SQL trực tiếp, không đi qua mã ứng dụng. Khoá nào còn sống tiếp tục trả giá cũ đến hết chu kỳ. Khoá nào hết hạn lúc 00:00 và được nạp lại trước khi tác vụ tới sản phẩm của nó cũng nhận giá cũ thêm một chu kỳ nữa. Với tốc độ khoảng 50 sản phẩm mỗi giây, tác vụ cần 12.000 ÷ 50 = 240 giây để đi hết danh sách, nên khoảng thời gian nạp nhầm này kéo dài nhiều phút.

Trang thanh toán đọc giá trực tiếp từ cơ sở dữ liệu, nên tính đúng. Thiết kế này đúng nguyên tắc: thao tác liên quan đến tiền phải đọc từ nguồn gốc. Tuy vậy, khách thấy hai giá khác nhau trên hai trang. Với TTL cố định và thời điểm cập nhật ngẫu nhiên trong chu kỳ, một khoá chưa được xoá giữ giá cũ trung bình 600 ÷ 2 = 300 giây.

Xoá hay cập nhật, trước hay sau khi ghi

Có hai cách vô hiệu hoá: ghi giá trị mới vào khoá, hoặc xoá khoá để lần đọc sau nạp lại. Xoá an toàn hơn khi có ghi đồng thời. Giả sử hai thao tác ghi cùng một sản phẩm, và thao tác COMMIT trước lại cập nhật Redis sau. Khi đó Redis giữ giá trị của thao tác cũ hơn. Lệnh DEL không mang giá trị nào, nên thứ tự giữa hai lệnh xoá không làm sai kết quả.

Thứ tự giữa ghi và xoá cũng quan trọng. Nếu xoá khoá trước rồi mới ghi cơ sở dữ liệu, một yêu cầu đọc xen vào giữa hai bước sẽ nạp lại giá cũ. Cửa sổ rủi ro khi đó dài bằng cả thời gian của giao dịch (transaction). Thứ tự đúng là COMMIT trước, xoá sau. Lệnh xoá đặt bên trong giao dịch, trước COMMIT, cũng mắc lỗi giống như xoá trước.

Kéo ngang để xem hết hình

TRANH CHẤP CÒN LẠI KHI GHI CƠ SỞ DỮ LIỆU RỒI XOÁ KHOÁ Luồng đọc Luồng ghi Khoá trong Redis GET: trượt t1 SELECT: giá cũ t2 UPDATE, COMMIT t3 DEL khoá t4 SET giá cũ t5 trống, mọi lần đọc đều trượt giá cũ đến khi hết TTL Điều kiện: SELECT bắt đầu trước COMMIT, và SET đến sau DEL.
Hình 4.5. Lệnh DEL đến trước lệnh SET nên không có tác dụng. Khung đỏ ở hàng dưới là khoảng thời gian khách thấy giá cũ, chỉ chấm dứt khi khoá hết hạn hoặc bị xoá lần nữa.

Ngay cả với thứ tự đúng vẫn còn một điều kiện tranh chấp (race condition), tức tình huống kết quả phụ thuộc vào thứ tự xen kẽ giữa các thao tác của hai luồng. Một luồng đọc trượt, đọc giá cũ từ cơ sở dữ liệu, rồi bị chậm lại. Trong lúc đó luồng ghi COMMIT và xoá khoá. Luồng đọc tiếp tục và ghi giá cũ vào Redis. Điều kiện này hiếm, vì truy vấn đọc phải kéo dài hơn cả thao tác ghi lẫn lệnh xoá, nhưng không bằng không.

TTL đóng vai trò lưới an toàn cho mọi trường hợp còn sót: điều kiện tranh chấp trên, lệnh xoá thất bại vì Redis tạm thời không phản hồi, hay một đường ghi bị quên. Dữ liệu cũ khi đó tồn tại không quá một chu kỳ. Một số hệ thống thêm lần xoá thứ hai sau vài trăm mili giây để thu hẹp điều kiện tranh chấp. Mức độ cũ chấp nhận được là một quyết định về tính nhất quán, được phân tích ở chương 12.

Sửa tác vụ đổi giá

Tác vụ đổi giá được sửa để xoá khoá sau mỗi lô đã COMMIT. Lô gồm cả khoá sản phẩm lẫn khoá các trang danh mục chứa sản phẩm đó, vì trang danh mục cũng lưu giá.

java// Tác vụ đổi giá lúc 00:00 sau khi sửa: ghi cơ sở dữ liệu trước, xoá khoá sau khi COMMIT
for (List<Long> batch : Lists.partition(productIds, 500)) {      // Guava: chia lô 500 sản phẩm
    tx.executeWithoutResult(status ->                             // Spring TransactionTemplate
        batch.forEach(id -> jdbc.update(
            "UPDATE products SET price = ? WHERE id = ?", newPrice(id), id)));
    // tới đây giao dịch đã COMMIT
    Set<String> keys = new HashSet<>();
    for (long id : batch) {
        keys.add("product:v2:" + id);
        keys.addAll(categoryKeysOf(id));                          // trang danh mục cũng chứa giá
    }
    redis.del(keys.toArray(new String[0]));
}

Lệnh DEL nhận nhiều khoá trong một lần gọi và trả số khoá đã xoá. Với giá trị lớn, lệnh UNLINK giải phóng bộ nhớ ở một luồng khác nên không chặn Redis. Khoá sản phẩm chỉ vài KB nên DEL là đủ.

Cách sửa này vẫn dựa vào việc mọi đường ghi nhớ xoá khoá. Một cách bao quát hơn là đọc nhật ký thay đổi của cơ sở dữ liệu và xoá khoá theo từng thay đổi, kỹ thuật được trình bày ở chương 13. Đến đây, phần dữ liệu đọc từ cơ sở dữ liệu đã được giải quyết. Phần lớn số byte mà khách tải về lại không đến từ cơ sở dữ liệu.

CDN cho ảnh và tệp tĩnh

Mỗi trang của Chợ Phố gồm một tệp HTML nhỏ và hàng chục ảnh, tệp JS, tệp CSS. Từ chương 2, ảnh nằm trong kho đối tượng và được tải qua máy gốc ở một trung tâm dữ liệu. Khách ở xa trung tâm dữ liệu chịu thêm thời gian đi về cho mỗi tệp.

Mạng phân phối nội dung (Content Delivery Network, CDN) là một mạng các máy chủ đặt tại nhiều điểm gần người dùng, gọi là điểm biên (edge, point of presence). Mỗi điểm biên là một bộ nhớ đệm dùng chung cho mọi khách trong khu vực. Khi khách yêu cầu một tệp, hệ thống phân giải tên miền đưa khách tới điểm biên gần nhất (chương 26). Trúng thì điểm biên trả ngay, trượt thì điểm biên hỏi máy gốc, lưu lại rồi trả.

Kéo ngang để xem hết hình

CDN ĐẶT GIỮA KHÁCH HÀNG VÀ MÁY GỐC Khách ở Hà Nội Khách ở Đà Nẵng Khách ở TP.HCM Điểm biên phía Bắc trúng: trả ngay Điểm biên phía Nam trúng: trả ngay Kho ảnh ảnh sản phẩm, JS, CSS Máy ứng dụng HTML, API giá Nét liền: phục vụ tại điểm biên. Nét đứt: chỉ khi trượt, điểm biên hỏi máy gốc.
Hình 4.6. Máy gốc chỉ thấy lượt trượt của các điểm biên, không thấy từng khách. Mỗi điểm biên giữ bản riêng, nên vô hiệu hoá phải đến được mọi điểm biên.
  1. Số lượt tải ảnh mỗi ngày. Mỗi người dùng xem khoảng 8 trang, mỗi trang 12 ảnh: 150.000 × 8 × 12 = 14.400.000 lượt tải ảnh.
  2. Dung lượng. Với ảnh trung bình 80 KB và 1 KB = 1.000 byte: 14.400.000 × 80 KB = 1.152 GB mỗi ngày.
  3. Phần máy gốc còn phải gửi. Với tỉ lệ trúng tại điểm biên 95%: 1.152 × 0,05 = 57,6 GB mỗi ngày.
Trình duyệt và CDN đọc cùng một header: Cache-Control. Máy gốc dùng header này để nói phản hồi được lưu ở đâu và trong bao lâu. Bảng dưới liệt kê các chỉ thị Chợ Phố dùng.
Chỉ thịÁp dụng choÝ nghĩa
max-age=NMọi bộ nhớ đệmPhản hồi còn mới trong N giây
s-maxage=NChỉ bộ nhớ đệm dùng chungGhi đè max-age tại CDN và proxy
immutableMọi bộ nhớ đệmNội dung không đổi trong thời gian còn mới, khỏi hỏi lại (RFC 8246)
stale-while-revalidate=NMọi bộ nhớ đệmĐược trả bản đã cũ trong N giây trong khi hỏi lại ở nền (RFC 5861)
privateBộ nhớ đệm dùng chungKhông được lưu; chỉ trình duyệt được lưu
no-storeMọi bộ nhớ đệmKhông được lưu bất kỳ phần nào
http# Ảnh sản phẩm: tên tệp chứa mã băm nội dung, nên nội dung của một URL không bao giờ đổi
GET /img/p42-3f9c2a.webp
Cache-Control: public, max-age=31536000, immutable

# JS, CSS đã đóng gói: cùng nguyên tắc
GET /static/app.8d41e0.js
Cache-Control: public, max-age=31536000, immutable

# Trang HTML sản phẩm: trình duyệt hỏi lại mỗi lần, điểm biên giữ 60 giây
GET /san-pham/42
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30

# Giá và tồn kho: không lưu ở điểm biên, đọc qua Redis đã vô hiệu hoá đúng
GET /api/gia/42
Cache-Control: no-store

# Giỏ hàng: dữ liệu riêng của từng khách
GET /gio-hang
Cache-Control: private, no-store

Ảnh và tệp đóng gói được đặt thời hạn một năm vì tên tệp chứa mã băm của nội dung. Khi ảnh đổi, tên tệp đổi theo, và trang HTML trỏ tới URL mới. URL cũ không bao giờ cần vô hiệu hoá. Đây là cách vô hiệu hoá CDN rẻ nhất: không vô hiệu hoá gì cả, chỉ đổi tên.

Các nhà cung cấp CDN đều có API xoá nội dung khỏi điểm biên, nhưng đây là API riêng của từng nhà cung cấp, không thuộc chuẩn HTTP. Lệnh xoá cần thời gian để tới mọi điểm biên, và thường có hạn mức. Ví dụ, CloudFront miễn phí 1.000 đường dẫn vô hiệu hoá đầu tiên mỗi tháng và tính phí phần vượt. Với ảnh sản phẩm, dùng tên tệp có mã băm tốt hơn gọi API xoá.

Bộ nhớ đệm tại biên cho nội dung động

Trang HTML của một sản phẩm thay đổi khi mô tả, giá hoặc tồn kho thay đổi. Trang này vẫn lưu được tại điểm biên trong thời gian ngắn, nhờ chỉ thị chỉ áp dụng cho bộ nhớ đệm dùng chung. Với cấu hình ở trên, trình duyệt luôn hỏi lại, còn điểm biên giữ trang trong một phút. Trong khoảng đó, mọi khách cùng khu vực nhận cùng một bản, và máy gốc chỉ dựng trang khoảng một lần mỗi phút cho mỗi điểm biên.

Chỉ thị stale-while-revalidate xử lý dồn tải ở tầng CDN theo cùng ý tưởng làm mới sớm. Khi bản lưu vừa hết hạn, điểm biên vẫn trả bản cũ cho khách và hỏi máy gốc ở nền. Khách không phải chờ, và máy gốc không nhận một loạt yêu cầu trùng.

Bộ nhớ đệm tại biên (edge cache) mang theo bài toán giá cũ của Redis, nay ở quy mô lớn hơn. Nếu giá nằm trong HTML, giá cũ có thể sống tới hết thời hạn ở mọi điểm biên. Xoá theo mẫu ký tự đại diện, như /san-pham/*, rẻ nhưng xoá luôn cả những trang không đổi giá, và máy gốc phải dựng lại toàn bộ cùng lúc. Xoá chính xác từng trang thì một đêm khuyến mãi đã chạm tới số trang lớn hơn nhiều lần hạn mức miễn phí của cả tháng. Nhóm vì thế tách giá và tồn kho khỏi HTML thành một API nhỏ không lưu tại biên. API này đọc qua Redis, nơi việc vô hiệu hoá đã được làm đúng.

Trang giỏ hàng và trang tài khoản chứa dữ liệu riêng của từng khách. Chúng phải mang private hoặc no-store, nếu không một điểm biên có thể trả giỏ hàng của khách này cho khách khác. Lỗi cấu hình loại này nguy hiểm hơn mọi lỗi giá cũ, nên mặc định an toàn là không lưu, và chỉ bật lưu cho từng đường dẫn đã xét.

Đi sâu: hỏi lại có điều kiện, header Age và Vary

Khi bản lưu đã cũ, điểm biên không nhất thiết tải lại toàn bộ. Nó gửi yêu cầu có điều kiện kèm If-None-Match chứa ETag của bản đang có. Nếu nội dung chưa đổi, máy gốc trả 304 Not Modified không kèm nội dung, và điểm biên tiếp tục dùng bản cũ thêm một thời hạn mới.

http# Điểm biên hỏi lại máy gốc khi bản lưu đã cũ
GET /san-pham/42
If-None-Match: "v-8c1d"

HTTP/1.1 304 Not Modified
ETag: "v-8c1d"
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30

Header Age trong phản hồi từ CDN cho biết số giây kể từ khi phản hồi được tạo hoặc được xác nhận lại ở máy gốc. Đây là cách nhanh nhất để biết một trang đến từ điểm biên hay từ máy gốc.

Header Vary liệt kê các header yêu cầu ảnh hưởng tới nội dung trả về, và bộ nhớ đệm dùng chúng làm một phần của khoá. Vary: Accept-Encoding là hợp lý vì chỉ có vài giá trị. Vary: Cookie làm mỗi khách có một khoá riêng, và tỉ lệ trúng tại biên gần như bằng không.

Hướng tiếp theo

Sau các thay đổi trong chương, Chợ Phố có hai tầng lưu đệm. Redis giữ dữ liệu sản phẩm với TTL có jitter, có gộp yêu cầu khi trượt, và được xoá khoá sau mỗi lần đổi giá. CDN phục vụ ảnh và tệp tĩnh, giữ HTML sản phẩm trong thời gian ngắn. Đêm khuyến mãi tiếp theo, đường CPU của cơ sở dữ liệu gần như phẳng.

Bộ nhớ đệm chỉ giảm tải đọc. Mỗi đơn hàng, mỗi lần thanh toán, mỗi đánh giá vẫn phải ghi vào cơ sở dữ liệu, và lượng ghi tăng theo số người dùng. Dung lượng dữ liệu cũng tăng mỗi ngày. Quan trọng hơn, cơ sở dữ liệu vẫn chạy trên một máy duy nhất, tức vẫn là điểm lỗi đơn (single point of failure) như đã phân tích ở chương 1.

Có thể chia tải đọc còn lại cho các bản sao (replica) của cơ sở dữ liệu không, và khi đó khách có đọc được dữ liệu mình vừa ghi? Khi máy cơ sở dữ liệu hỏng, máy nào thay thế, và mất bao lâu? Khi một máy không còn chứa nổi dữ liệu, nên chia dữ liệu theo tiêu chí nào? Chương 5 phân tích các câu hỏi này.

Tóm tắt chương

  1. Bộ nhớ đệm có ích khi dữ liệu được đọc nhiều lần trước khi đổi và truy cập phân bố lệch. Cái giá luôn là khả năng trả dữ liệu cũ.
  2. Bộ nhớ đệm trong tiến trình nhanh nhất nhưng mỗi máy giữ một bản riêng. Bộ nhớ đệm dùng chung như Redis cho mọi máy cùng một bản, đổi lại một lượt đi về qua mạng.
  3. Bốn mẫu đọc ghi khác nhau ở chỗ ai nạp và ai ghi. Nạp khi cần đơn giản và chịu được Redis ngừng hoạt động. Ghi sau có thể mất dữ liệu.
  4. Với TTL cố định, số lần trượt có cận trên bằng số khoá chia cho TTL. Tỉ lệ trúng đo bằng keyspace_hits và keyspace_misses.
  5. Khoá nạp cùng lúc với TTL cố định sẽ hết hạn cùng lúc, theo chu kỳ. Mỗi khoá trượt sinh khoảng 1 + λ × W truy vấn, và W tăng theo mức sử dụng của cơ sở dữ liệu.
  6. Chống dồn tải bằng TTL có jitter, gộp yêu cầu trùng, khoá phân tán ngắn và làm mới sớm.
  7. Vô hiệu hoá bằng cách xoá khoá sau khi COMMIT, trên mọi đường ghi, kể cả tác vụ chạy SQL trực tiếp. TTL là lưới an toàn cho điều kiện tranh chấp còn lại.
  8. Thao tác liên quan đến tiền đọc từ nguồn gốc, không đọc từ bộ nhớ đệm.
  9. CDN lưu tệp tĩnh tại điểm biên gần khách. Tên tệp chứa mã băm nội dung thay cho việc gọi API xoá.
  10. s-maxage và stale-while-revalidate cho phép lưu nội dung động trong thời gian ngắn. Dữ liệu riêng của khách phải mang private hoặc no-store.

Tài liệu tham khảo

  • Redis Documentation: các lệnh SET, SETEX, DEL, UNLINK, TTL, INFO, CONFIG RESETSTAT; trang "Key eviction"; trang "Distributed Locks with Redis"; trang "Persistence".
  • Memcached: mã nguồn memcached.c (giá trị mặc định của -t và -I); doc/protocol.txt (lệnh add).
  • Caffeine: Javadoc của Cache.get(key, mappingFunction); wiki "Eviction".
  • AWS ElastiCache: trang "Caching strategies" (lazy loading, write-through, TTL); CloudFront: trang về giá vô hiệu hoá.
  • RFC 9111 (HTTP Caching), RFC 9110 (HTTP Semantics: If-None-Match, 304, Vary), RFC 5861 (stale-while-revalidate), RFC 8246 (immutable).
  • A. Vattani, F. Chierichetti, K. Lowenstein. "Optimal Probabilistic Cache Stampede Prevention". Proceedings of the VLDB Endowment, tập 8, số 8, 2015.

Chợ Phố là tình huống giả định. Các phép tính được kiểm lại bằng _build/c04_calc.py; giá trị mặc định, cú pháp lệnh và hành vi phần mềm được đối chiếu tài liệu chính thức trong _build/c04-verify.md.