Distributed cache (Redis / Memcached) khi rollback blue-green

Blue-green cho ta cảm giác an toàn: bấm rollback là nginx trỏ lại colour cũ, mất 1 giây, code y như trước. Nhưng code quay về được, cache thì không. Redis nằm ngoài cả hai colour, không có "phiên bản", không biết ai vừa rollback. Sau khi rollback, code cũ đọc phải dữ liệu mà code mới vừa ghi vào — và nó không tự lành, vì giá trị hỏng đã nằm trong cache với TTL 24 giờ.

Bài này nói về đúng chỗ đó: vì sao hỏng, bốn cách xử lý, cách nào đắt cách nào rẻ, và những cái bẫy đo được bằng số thật.

Từ điển ngắn — các thuật ngữ giữ nguyên tiếng Anh trong bài:

  • blue-green deployment — chạy song song 2 bản code (blue và green), chỉ 1 bản nhận traffic; deploy = đổi bản đang nhận traffic.
  • rollback — trỏ traffic ngược lại colour cũ.
  • distributed cache — cache nằm ở server riêng (Redis, Memcached), mọi app node dùng chung. Khác với cache trong process (opcache, biến static) vốn chết theo container.
  • cache poisoning (cache bị nhiễm) — cache chứa giá trị mà bên đọc không xử lý được, hoặc xử lý ra kết quả sai.
  • shape drift (lệch hình dạng dữ liệu) — cấu trúc của giá trị trong cache khác với cấu trúc mà code đang đọc mong đợi.
  • namespace versioning — gắn số phiên bản vào tiền tố key, đổi số là coi như có một vùng cache mới hoàn toàn.
  • cache stampede / thundering herd (giẫm đạp) — cache trống, hàng nghìn request cùng lúc đâm thẳng xuống database.
  • TTL — time to live, thời gian sống của một key trước khi Redis tự xoá.
  • SCAN — lệnh Redis duyệt key theo từng lô nhỏ, không khoá server; ngược với KEYS duyệt hết trong một lần.

Môi trường đã dùng để kiểm chứng. Mọi số liệu và thông báo lỗi trong bài đều chạy thật, không chép từ trí nhớ: Redis 8.8.0 standalone, Memcached 1.6.45, PHP 8.3.33, Laravel Framework 12.64.0. Chỗ nào chưa verify được sẽ ghi rõ "chưa verify".


1. Nó là gì / chạy thế nào

1.1. Vì sao cache không rollback theo code

Trong một stack blue-green điển hình, hai colour là hai container khác image, nhưng state nằm ngoài cả hai: PostgreSQL và Redis là dịch vụ dùng chung.

flowchart TB
  U["Người dùng"] --> N["nginx proxy
active-upstream.conf"] N -->|"đang trỏ tới"| G["app_green
image build 42"] N -.->|"idle nhưng vẫn chạy"| B["app_blue
image build 41"] G --> R[("Redis
CACHE_PREFIX dùng chung")] B --> R G --> D[("PostgreSQL")] B --> D W["worker + scheduler
chạy image của colour live"] --> R W --> D

Rollback chỉ đổi một dòng trong file upstream của nginx rồi nginx -s reload. Nó không đụng gì tới Redis. Nên sau rollback ta có tình trạng này:

Cache là chỗ tệ nhất trong ba cái, vì hai lý do:

  1. Nó không có schema. Database ép ta khai báo cột; cache thì nhận bất cứ chuỗi byte nào. Không có ai chặn lỗi giúp.
  2. Nó sống lâu hơn sự cố. Rollback mất 1 phút. TTL cache thường 1–24 giờ. Nghĩa là sau khi "đã rollback xong", lỗi còn kéo dài thêm nhiều giờ nữa.

1.2. Luồng hỏng, kể từng bước

sequenceDiagram
  autonumber
  participant U as User
  participant N as nginx
  participant G as app_green build 42
  participant B as app_blue build 41
  participant R as Redis
  N->>G: request GET /product/1
  G->>R: SET product:1 = object 3 property, TTL 24h
  Note over R: cache đã nhiễm shape mới
  Note over N: phát hiện lỗi, chạy bluegreen.sh rollback
  N->>B: request GET /product/1
  B->>R: GET product:1
  R-->>B: object 3 property, do green ghi
  Note over B: unserialize ra object,
đọc property build 41 không có
hoặc thiếu property build 41 cần B-->>U: HTTP 500 Note over B,R: mỗi request tiếp theo lặp lại y hệt
suốt 24h cho tới khi TTL hết

1.3. Bốn kiểu nhiễm cache, xếp theo mức nguy hiểm

#KiểuBiểu hiện sau rollbackMức
1Shape drift — cache object đã serialize, class đổi propertyFatal error, HTTP 500 lặp lạiNặng nhất
2Semantic drift — cùng shape, khác ý nghĩa (giá đổi từ đồng sang xu)Không lỗi, chỉ sai số. Sai âm thầm.Nguy hiểm nhất vì không ai thấy
3Poisoned value — build mới có bug tính sai, kết quả sai đã nằm trong cacheRollback code xong vẫn saiNặng
4Key drift — build mới đổi cách sinh keyChỉ là cache miss, tự tính lạiNhẹ, chỉ tốn CPU/DB

Nhiều người chỉ lo kiểu 1 vì nó ồn ào (500 đầy log). Kiểu 2 mới là kiểu làm mất tiền: không có exception, không có alert, chỉ có báo cáo doanh thu lệch và một tuần sau kế toán mới hỏi.

1.4. Kiểu 1 trông ra sao — chạy thật

Build 42 (green) thêm property scale vào một value object rồi cache nó:

// build 42 — app_green
class Money {
    public function __construct(
        public int $amount,
        public string $currency,
        public int $scale = 2,   // property mới
    ) {}
}
Cache::put('price:1', new Money(10000, 'VND', 2), 86400);

Chuỗi thật Redis nhận được:

O:5:"Money":3:{s:6:"amount";i:10000;s:8:"currency";s:3:"VND";s:5:"scale";i:2;}

Trường hợp A — build cũ không có property đó. PHP 8.3 không fatal, nó tạo dynamic property và chỉ cảnh báo:

PHP Deprecated: Creation of dynamic property Money::$scale is deprecated

Chạy được, nhưng nếu class có declare(strict_types=1) kèm #[\AllowDynamicProperties] bị bỏ, hoặc lên PHP 9, chỗ này sẽ thành fatal. Nó là quả bom hẹn giờ, không phải chuyện đã yên.

Trường hợp B — ngược lại: build mới BỎ một property mà build cũ cần. Đây mới là chỗ chết. Với typed property (PHP 7.4+), giá trị thiếu không thành null, nó thành uninitialized:

object(Money)#1 (2) {
  ["amount"]=> int(10000)
  ["currency"]=> string(3) "VND"
  ["scale"]=> uninitialized(int)
}

PHP Fatal error: Uncaught Error: Typed property Money::$scale
must not be accessed before initialization

Trường hợp C — build mới cache một class mà build cũ chưa có. unserialize() không ném exception, nó trả về một object rỗng ruột:

object(__PHP_Incomplete_Class)#1 (2) {
  ["__PHP_Incomplete_Class_Name"]=> string(12) "PriceQuoteV2"
  ["total"]=> int(99)
}

PHP Warning: Unknown: The script tried to access a property on an
incomplete object. Please ensure that the class definition "PriceQuoteV2"
of the object you are trying to operate on was loaded _before_
unserialize() gets called...

Chú ý: đọc property của nó trả về chuỗi rỗng kèm Warning, không ném lỗi. Nếu app đang chạy display_errors=0 thì đây là một lỗi hoàn toàn im lặng — lại về kiểu 2.

1.5. Nhìn key thật trong Redis

Laravel ghép hai lớp tiền tố, nhiều người tưởng chỉ có một (chạy thật trên Laravel 12.64.0):

clicon-database-clicon_devdoc:plain
└──────┬──────┘└───┬────┘└───┬───┘
       │           │         └── key ứng dụng
       │           └── config('cache.prefix')            (CACHE_PREFIX)
       └── config('database.redis.options.prefix')       (REDIS_PREFIX)

Key có tag thì chèn thêm một đoạn băm (hash) của danh sách tag vào giữa:

clicon-database-clicon_dev6266bc2904f983980f02a3d69bd7b8985f76a5d0:doc:tagged

Vì sao phải biết? Vì mọi script purge (dọn cache) theo pattern phải khớp đúng cả hai tiền tố, nếu không sẽ xoá hụt hoặc xoá lố.


2. Bốn cách xử lý — cơ chế từng cách

flowchart TD
  S["Rollback blue-green,
cache dùng chung"] --> Q1{"Cache có chứa
object đã serialize?"} Q1 -->|"Có"| C["Cách C
bỏ object, chỉ cache scalar/array
(sửa tận gốc)"] Q1 -->|"Không"| Q2{"Được phép
tốn gấp đôi RAM Redis?"} Q2 -->|"Được"| A["Cách A
versioned prefix theo build"] Q2 -->|"Không"| Q3{"Số key cần bỏ
có khoanh vùng được?"} Q3 -->|"Được"| B2["Cách B
envelope version trong value"] Q3 -->|"Không"| D["Cách D
purge SCAN + UNLINK lúc rollback"]

2.1. Cách A — versioned cache prefix (mỗi build một namespace)

Ý tưởng: gắn số build vào CACHE_PREFIX. Build 41 và build 42 ghi vào hai vùng key hoàn toàn tách nhau. Rollback = quay lại prefix cũ, và cache cũ vẫn còn nguyên, còn ấm, còn đúng TTL.

# app.env của colour blue
CACHE_PREFIX=clicon-cache-v41-

# app.env của colour green
CACHE_PREFIX=clicon-cache-v42-

Kiểm chứng trên Redis 8.8.0 — hai thế hệ sống song song, không đụng nhau:

redis> SET clicon-cache-v41:product:1 "shape-cu"  EX 3600
OK
redis> SET clicon-cache-v42:product:1 "shape-moi" EX 3600
OK
redis> GET clicon-cache-v41:product:1
"shape-cu"          <-- blue đọc lại đúng bản của mình sau rollback
redis> GET clicon-cache-v42:product:1
"shape-moi"
redis> TTL clicon-cache-v41:product:1
(integer) 3598      <-- TTL không bị reset, cache vẫn ấm

Với script bluegreen.sh kiểu chuẩn (mỗi colour một app.env), chỗ sửa rất nhỏ — truyền image tag vào biến môi trường của colour đích:

# bluegreen.sh, trong deploy()
# image = registry/clicon:9f3a21c  ->  lấy phần tag làm version cache
CACHE_VERSION="${image##*:}"

compose run --rm -e CACHE_PREFIX="clicon-cache-${CACHE_VERSION}-" ...
# hoặc ghi thẳng vào .env của compose rồi
compose up -d --no-deps --force-recreate "app_$target"

Bẫy 1 của cách A: gấp đôi bộ nhớ. Đo thật với 200.000 key, mỗi value 200 byte:

1 thế hệ prefix : used_memory_human = 56.90M
2 thế hệ prefix : used_memory_human = 112.01M   (DBSIZE = 400000)

Đúng gấp đôi. Nếu Redis đang chạy maxmemory-policy noeviction (đây là mặc định, đã verify: CONFIG GET maxmemory-policy trả về noeviction) thì khi chạm maxmemory, Redis sẽ từ chối mọi lệnh ghi với lỗi OOM command not allowed when used memory > 'maxmemory' — nghĩa là deploy làm sập luôn cả session và queue nếu chúng dùng chung instance.

→ Trước khi bật cách A: đặt maxmemory và đổi policy sang allkeys-lru, hoặc chắc chắn RAM dư gấp 2,5 lần mức đang dùng.

Bẫy 2 của cách A: mỗi deploy là một lần cache trống. Prefix mới = 0 key = 100% miss. Với hệ thống đang chạy vài nghìn request/phút, đó là cache stampede đâm thẳng vào database ngay lúc deploy — đúng lúc ta ít muốn có sự cố nhất. Cách xử lý ở mục 6.4.

Bẫy 3 của cách A: key không TTL sẽ sống mãi. Prefix cũ không có ai xoá. Nếu trong cache có key forever (Laravel Cache::forever()) thì sau 20 lần deploy ta có 20 thế hệ rác nằm lại. Cần một job dọn prefix cũ hơn N build, hoặc quy tắc "cấm Cache::forever, mọi key phải có TTL".

2.2. Cách B — envelope version nằm trong value

Giữ nguyên một prefix, nhưng bọc giá trị trong một lớp vỏ có mang số phiên bản. Đọc ra thấy version không khớp thì coi như cache miss.

// app/Support/VersionedCache.php
final class VersionedCache
{
    // Chỉ bump khi shape của value đổi. Không bump theo mỗi build.
    private const SHAPE = 3;

    public static function remember(string $key, int $ttl, Closure $make): mixed
    {
        $hit = Cache::get($key);
        if (is_array($hit) && ($hit['v'] ?? null) === self::SHAPE) {
            return $hit['d'];
        }
        $value = $make();
        Cache::put($key, ['v' => self::SHAPE, 'd' => $value], $ttl);
        return $value;
    }
}

Sau rollback, build 41 (SHAPE = 2) đọc phải giá trị build 42 ghi (SHAPE = 3) → không khớp → tính lại và ghi đè bằng shape 2. Tự lành, không cần ai chạy lệnh gì.

Điểm mạnh so với cách A: không nhân đôi bộ nhớ, và chỉ những key thật sự đổi shape mới mất cache. Điểm yếu: phải nhớ bump SHAPE — mà "phải nhớ" là thứ luôn hỏng lúc 5 giờ chiều thứ Sáu. Nên gắn nó vào code review: đổi struct của một cached DTO mà không bump SHAPE = reject PR.

Bẫy: cách B không cứu được kiểu 2 (semantic drift). Nếu build 42 đổi price từ đồng sang xu mà shape vẫn là int, việc bump SHAPE lại phải do người nhận ra — mà người đó thường không nhận ra. Cách chắc hơn: đổi tên field luôn (priceprice_cents), để lệch nghĩa biến thành lệch shape, và shape thì máy bắt được.

2.3. Cách C — đừng cache object (sửa tận gốc)

Cả ba trường hợp fatal ở mục 1.4 đều xuất phát từ đúng một việc: ghi PHP object đã serialize vào một kho tồn tại lâu hơn code. Bỏ việc đó đi thì cả lớp lỗi biến mất.

// Xấu — gắn cache vào định nghĩa class
Cache::put("price:$id", new Money(10000, 'VND'), 86400);

// Tốt — cache dữ liệu thuần, dựng object lại lúc đọc
Cache::put("price:$id", ['amount' => 10000, 'currency' => 'VND'], 86400);
$money = Money::fromArray(Cache::get("price:$id"));   // fromArray tự lo field thiếu

Lúc này shape drift thoái hoá thành "thiếu key trong array" — thứ mà $data['scale'] ?? 2 xử lý được trong một dòng, và không bao giờ fatal.

Đây là cách rẻ nhất về vận hành: không sửa hạ tầng, không thêm bước deploy, không tốn thêm RAM. Cái giá là phải rà lại chỗ nào đang cache object — gồm cả những chỗ ta không nghĩ là cache: queue job payload cũng là serialize object và cũng sống qua rollback (mục 5.4).

Với Eloquent model thì đây không phải lời khuyên, mà gần như bắt buộc: serialize một model kéo theo cả $attributes, $relations, $casts đã resolve. Chỉ cần build mới thêm một cast là bản cache cũ đọc ra sai kiểu. Xem thêm Blade nhận gì — Model, array hay Collection.

2.4. Cách D — purge có chọn lọc lúc rollback

Khi không kịp làm A/B/C (sự cố đang diễn ra), cách còn lại là xoá thẳng những key nghi ngờ. Ở đây có một quyết định quan trọng: KEYS hay SCAN.

Đo thật: KEYS vs SCAN + UNLINK trên 200.000 key

CáchTổng thời gianLệnh chậm nhất (block server)Số lệnh > 1ms
KEYS 'prefix:*'0,35 s89,4 ms (một cú duy nhất)1
SCAN COUNT 1000 + UNLINK theo lô 50011,24 s7,5 ms128

Đọc bảng này cho đúng: KEYS nhanh hơn 32 lần về tổng thời gian. Nhưng Redis chạy một luồng (single-threaded) — trong 89 ms đó mọi client khác đều đứng. Với 5.000 request/giây, 89 ms nghĩa là ~445 request bị treo cùng lúc, đủ để lấp đầy pool connection PHP-FPM và tạo ra một đợt 502 dây chuyền (xem Scale Laravel với nginx + PHP-FPM). SCAN chậm gấp 32 lần nhưng chưa bao giờ giữ server quá 7,5 ms.

# Purge theo prefix, không khoá Redis.
# --count 1000: mỗi vòng SCAN lấy ~1000 key. -n 500: mỗi UNLINK xoá 500 key.
redis-cli --scan --pattern 'clicon-database-clicon-cache-v42-*' --count 1000 \
  | xargs -n 500 redis-cli UNLINK

UNLINK chứ không phải DEL: UNLINK gỡ key khỏi keyspace ngay rồi giải phóng bộ nhớ ở luồng nền, nên không block khi xoá value lớn.

Bẫy nặng nhất của cách D: SCAN không đảm bảo thấy key sinh ra giữa chừng. Redis chỉ hứa: key tồn tại từ đầu đến cuối vòng lặp thì chắc chắn được trả về. Key tạo ra trong lúc đang SCAN thì có thể bị bỏ sót.

Đo thật: 50.000 key có sẵn, giữa vòng SCAN chèn thêm 5.000 key mới.

tổng key thực tế trong db : 55000
số key SCAN nhìn thấy     : 54903
số key SCAN bỏ sót        : 97

97 key sót lại — mà app vẫn đang chạy nên nó vừa xoá vừa có key mới đẻ ra. Nghĩa là purge một lần không sạch. Phải hoặc (a) chạy purge sau khi nginx đã trỏ về colour cũ và colour mới đã dừng ghi, hoặc (b) chạy 2 lượt, hoặc (c) chấp nhận vài key sót và dựa vào cách B để chúng tự chết.

Cái tuyệt đối không được làm: php artisan cache:clear

Nhìn tên thì tưởng nó xoá cache của app mình. Đọc source Laravel 12.64.0 (vendor/laravel/framework/src/Illuminate/Cache/RedisStore.php):

public function flush()
{
    $this->connection()->flushdb();

    return true;
}

FLUSHDBbỏ qua hoàn toàn prefix. Nó xoá sạch database Redis đó, kể cả key của colour kia, của môi trường khác, và của bất cứ ứng dụng nào lỡ dùng chung db index. Tài liệu Laravel nói thẳng: "This operation does not respect cache prefixes and will remove all entries."

Và nó block. Đo thật, FLUSHDB trên 400.000 key:

slowlog: FLUSHDB  312534 µs   →  block server 312 ms

Mặc định FLUSHDB chạy đồng bộ — đã verify CONFIG GET lazyfree-lazy-user-flush trả về no. Bản FLUSHDB ASYNC thì không hề xuất hiện trong slowlog ở ngưỡng 1 ms:

redis> FLUSHDB ASYNC
OK
redis> SLOWLOG GET 1
(empty array)      <-- không có lệnh nào chạm ngưỡng 1ms

Nhưng ASYNC có ngữ nghĩa riêng cần biết: nó chỉ xoá những key có mặt tại thời điểm gọi lệnh; key sinh ra trong lúc flush sẽ sống sót. Lại đúng cái bẫy "purge một lần không sạch" ở trên.


3. Lợi ích

CáchĐược gì cụ thể
A. Versioned prefix Rollback trả về đúng trạng thái cache trước đó, còn ấm (TTL không reset — đã đo: 3598/3600s). Đây là cách duy nhất khiến rollback thật sự là "quay ngược thời gian", không chỉ với code mà cả với cache. Không cần sửa một dòng code ứng dụng nào.
B. Envelope version Tự lành, không cần thao tác lúc rollback (quan trọng lúc 3 giờ sáng — càng ít bước tay càng ít sai). Không tốn thêm RAM. Chỉ mất cache ở đúng những key đổi shape.
C. Không cache object Xoá hẳn cả lớp lỗi fatal, không phải "giảm rủi ro". Rẻ nhất về vận hành: không thêm bước deploy, không thêm RAM, không thêm script. Kèm lợi ích phụ: cache nhỏ hơn (mảng gọn hơn object đã serialize) và đọc được bằng mắt khi debug.
D. Purge lúc rollback Là cách duy nhất dùng được khi sự cố đang xảy ra và ta chưa chuẩn bị gì trước. Cũng là cách duy nhất dọn được kiểu 3 (giá trị sai do bug đã nằm trong cache) — A và B không cứu được kiểu này nếu shape không đổi.

4. Tác hại / hạn chế

CáchMất gìChỗ dễ hỏng
A Gấp đôi RAM (đo thật: 56,9M → 112,0M). Mỗi deploy là một lần cache trống. Chạm maxmemory với policy mặc định noeviction → Redis từ chối mọi lệnh ghi. Key forever của prefix cũ không ai xoá, tích rác qua từng build.
B Phải nhớ bump số shape bằng tay. Mỗi lần đọc tốn thêm một phép so sánh (không đáng kể). Quên bump = bảo vệ bằng không, mà lại tưởng là có. Không bắt được semantic drift nếu tên field giữ nguyên.
C Code dài hơn: phải viết toArray()/fromArray() cho mỗi DTO được cache. Chỉ cần một chỗ lỡ tay Cache::put($key, $model) là lỗ hổng quay lại. Cần một test hoặc rule static analysis chặn (xem pint và static analysis).
D Cache trống ngay lúc hệ thống đang có sự cố — thời điểm tệ nhất để dồn tải xuống DB. SCAN bỏ sót key sinh ra giữa chừng (đo thật: 97/55.000). KEYS block 89 ms. cache:clear = FLUSHDB, xoá cả colour kia lẫn môi trường khác, block 312 ms trên 400k key.

4.1. Hạn chế chung: cache không phải thứ duy nhất trong Redis

Đây là chỗ hay bị bỏ sót. Trong một app Laravel điển hình, Redis giữ ít nhất:

Loại stateRollback có ảnh hưởng?Ghi chú
Cache (Cache::)Có — cả 4 kiểu ở mục 1.3Chủ đề chính của bài
SessionCó, nếu đổi prefix theo build → toàn bộ user bị logoutKhông bao giờ version-hoá prefix session
Queue (nếu dùng Redis queue)Có — job payload là object serialize, y hệt shape driftJob do green đẩy vào, blue lôi ra chạy
Cache lock (Cache::lock)Ít — TTL ngắn, tự hếtTrừ khi lock forever
Rate limiterÍt — đổi prefix chỉ làm reset bộ đếmReset counter có thể là lỗ hổng nhỏ, chấp nhận được

Cách tách sạch nhất là tách db index, không dùng chung một database Redis cho mọi thứ. Cấu hình đã kiểm chứng trên một stack thật:

database.redis.default.database = 6      # session, lock, rate limiter
database.redis.cache.database   = 7      # cache

session.driver     = redis
session.connection = null   → rơi về connection 'default' → db 6

Với cấu hình này, php artisan cache:clear gọi FLUSHDB trên db 7, nên session ở db 6 sống sót. Nếu để chung một db, cùng một lệnh đó sẽ đăng xuất toàn bộ người dùng — và người chạy lệnh thường không hề biết.

Redis Cluster thì không có lựa chọn này. Tài liệu Redis nói rõ: cluster chỉ hỗ trợ một database duy nhất (id 0), nên FLUSHDBFLUSHALLmột. Ở cluster, tách bằng prefix là cách duy nhất, và SCAN phải chạy trên từng node (redis-cli --cluster call), không thể quét cả cluster bằng một lệnh. Chưa verify trực tiếp — môi trường test là standalone; nội dung này lấy từ tài liệu Redis chính thức.


5. Trade-off — chọn cách nào

5.1. Bảng chọn

A. Versioned prefixB. Envelope versionC. Không cache objectD. Purge lúc rollback
Sửa ở đâuHạ tầng / script deployCode (một helper)Code (nhiều chỗ)Runbook / script vận hành
Thêm RAM×2KhôngGiảm nhẹKhông
Cache sau rollbackẤmLạnh ở key đổi shapeẤmLạnh toàn bộ
Cần thao tác tay lúc rollbackKhôngKhôngKhông
Chặn shape drift (kiểu 1)Chặn tận gốcCó, nếu purge đúng pattern
Chặn semantic drift (kiểu 2)Chỉ khi nhớ bumpKhông
Chặn poisoned value (kiểu 3)KhôngKhông
Rủi ro con ngườiThấp (tự động theo build tag)Cao (phải nhớ bump)Trung bình (phải nhớ không cache object)Cao (gõ sai pattern lúc 3h sáng)

5.2. Tiêu chí chọn, nói thẳng

Phương án hay bị đề xuất mà nên bác: "cho mỗi colour một Redis riêng". Nghe sạch, nhưng nó biến cache thành ra hai bản không nhất quán, và tệ hơn: lúc switch colour thì cache mới hoàn toàn trống — đúng vấn đề của cách A nhưng không có lợi ích nào của A, cộng thêm tiền một instance Redis nữa. Chỉ dùng khi cache là loại chỉ-đọc-được-dựng-sẵn (pre-warmed), tức là ta chủ động nạp đầy trước khi switch.

5.3. Memcached khác Redis ở chỗ nào

Nếu đang dùng Memcached, ba trong bốn cách trên phải đổi. Kiểm chứng thật trên Memcached 1.6.45:

Không có lệnh nào xoá theo prefix. Không SCAN, không pattern:

> delete app:v41:*
NOT_FOUND

Nó hiểu app:v41:* là tên key theo nghĩa đen. Cách D coi như không tồn tại với Memcached. Có thể liệt kê key bằng lru_crawler metadump all:

> lru_crawler metadump all
key=app%3Av41%3Aproduct%3A1 exp=-1 la=1788433841 cas=2 fetch=yes cls=1 size=84 flags=0
key=app%3Av41%3Aproduct%3A2 exp=-1 la=1788433841 cas=3 fetch=no  cls=1 size=84 flags=0
key=app%3Av42%3Aproduct%3A1 exp=-1 la=1788433841 cas=4 fetch=yes cls=1 size=85 flags=0
END

Nhưng đây là công cụ debug, không phải API vận hành: nó quét toàn bộ LRU, key bị URL-encode, và tài liệu Memcached khuyến cáo không dùng trong production.

Chỉ có flush_all — và nó xoá sạch, có kèm hẹn giờ:

> flush_all 2                    # hẹn 2 giây nữa
OK
> get app:v41:product:1         # ngay lập tức: vẫn còn
VALUE app:v41:product:1 0 8
shape-cu
END
--- 3 giây sau ---
> get app:v41:product:1
END                             # đã bay
> get app:v42:product:1
END                             # cả colour kia cũng bay theo

Cách thay thế cho A trên Memcached: generation counter. Vì không đổi được prefix bằng cách xoá, ta để một key đếm quyết định namespace, và key thật mang số đó trong tên:

// Đọc generation, ghép vào key. Rollback = trả generation về số cũ.
$gen = $mc->get('gen:product') ?: 41;
$value = $mc->get("app:v{$gen}:product:{$id}");
> set gen:product 0 0 2 / 41     → STORED
> incr gen:product 1             → 42        # deploy build mới
> decr gen:product 1             → 41        # rollback
> get app:v41:product:1
VALUE app:v41:product:1 0 8
shape-cu                                     # cache cũ vẫn còn nguyên

Đã verify: sau decr, dữ liệu của generation cũ vẫn đọc được bình thường — tức là cách A dùng được trên Memcached, chỉ là qua một lớp gián tiếp.

ViệcRedisMemcached 1.6.45
Xoá theo prefixSCAN + UNLINKKhông có
Xoá sạchFLUSHDB / FLUSHDB ASYNCflush_all [delay]
Hẹn giờ xoáKhông (phải tự làm)flush_all <giây>
Tách namespacePrefix, hoặc db index (trừ Cluster)Chỉ prefix + generation counter
Cache tagsCó (Laravel dựng bằng set)Không hỗ trợ trong Laravel
Xoá không blockUNLINKKhông cần — bộ nhớ theo slab, xoá là O(1)

5.4. Cùng một lớp lỗi, chỗ ít ai nhìn: queue job

Queue payload cũng là object serialize, cũng sống lâu hơn rollback:

sequenceDiagram
  autonumber
  participant G as app_green build 42
  participant Q as Queue
  participant W as worker
  G->>Q: push SendInvoice job, có property mới
  Note over G: rollback về build 41
  Note over W: worker được recreate theo colour live
nên worker giờ chạy build 41 W->>Q: pop job do build 42 đẩy vào Note over W: unserialize → thiếu property → job failed W->>Q: retry 3 lần rồi vào failed_jobs

Script blue-green thường recreate worker/scheduler sau khi switch, đúng với nguyên tắc "code đẩy job không bao giờ mới hơn code chạy job" quá lâu. Nhưng nguyên tắc đó chỉ đúng theo chiều tiến. Lúc rollback thì chiều ngược lại: worker lùi về build cũ, còn trong hàng đợi vẫn còn job của build mới.

Cách xử lý giống hệt cách C: job chỉ nhận scalar/id, không nhận object. new SendInvoice($invoice->id) chứ không phải new SendInvoice($invoice).


6. Khó khăn khi vận hành thực tế

6.1. Không ai đo được "cache đang nhiễm"

Đây là khó khăn lớn nhất. Ta có metric cho hit rate, có metric cho memory, nhưng không có metric cho "bao nhiêu % giá trị trong cache đang sai shape". Rollback xong, dashboard xanh hết, và lỗi thì nằm rải rác trong log của những request đen đủi trúng đúng key hỏng.

Cái đo được, nên dựng sẵn:

6.2. Lúc 3 giờ sáng, người trực không nhớ pattern

Cách D chết ở đây. Người trực đang buồn ngủ, gõ --pattern 'clicon-*' thay vì 'clicon-database-clicon-cache-v42-*', và xoá luôn cache của colour đang chạy. Hoặc gõ KEYS vì quen tay. Hoặc chạy php artisan cache:clear vì tên nó nghe hợp lý nhất trong mọi lệnh có thể nghĩ ra lúc đó.

Cách chống duy nhất hiệu quả: không để họ phải nghĩ. Gộp purge vào chính lệnh rollback, có in ra pattern trước khi chạy:

# trong bluegreen.sh rollback(), sau khi đã switch nginx
purge_cache_of() {
    local tag=$1 pattern
    pattern="${REDIS_PREFIX}${CACHE_PREFIX_BASE}${tag##*:}-*"
    echo "==> purge cache: $pattern"
    compose exec -T redis sh -c \
      "redis-cli --scan --pattern '$pattern' --count 1000 | xargs -r -n 500 redis-cli UNLINK" \
      | tail -1
}
# Purge cache của colour VỪA BỊ BỎ, không phải colour vừa quay về.
purge_cache_of "$ACTIVE_TAG"

Ba chi tiết quan trọng ở đoạn trên:

  1. Chạy sau khi nginx đã switch — lúc đó colour lỗi không còn nhận traffic nên không còn ghi key mới, tránh được bẫy "SCAN bỏ sót key sinh giữa chừng".
  2. xargs -r — không có -r thì khi không tìm thấy key nào, xargs vẫn gọi redis-cli UNLINK không tham số và trả về lỗi, làm script rollback fail dù rollback đã thành công.
  3. Purge cache của tag bị bỏ. Purge nhầm tag đang quay về = tự tay làm cache trống ngay lúc đang có sự cố.

6.3. Sau purge là cơn giẫm đạp

Cache trống + traffic thật = mọi request cùng miss cùng lúc, cùng đâm xuống database một câu query. Đây là lúc database thường sập chứ không phải lúc deploy.

Laravel 11.23+ có Cache::flexible() — stale-while-revalidate (trả bản cũ ngay, tính lại ở nền). Đã verify có trên Laravel 12.64.0:

// [5, 60]: trong 5 giây đầu coi là còn tươi.
// Từ giây 5 đến 60: TRẢ NGAY bản cũ cho user, đồng thời dispatch một job
// tính lại ở nền. Sau giây 60: hết hạn hẳn, phải tính đồng bộ.
$value = Cache::flexible('price:1', [5, 60], fn () => $this->computePrice(1));

Nó ghi hai key, không phải một — điều mà script purge phải biết:

clicon-database-clicon_devdoc:flex
clicon-database-clicon_devilluminate:cache:flexible:created:doc:flex

Nếu pattern purge chỉ khớp key chính mà bỏ sót key ...:flexible:created:..., Laravel sẽ thấy "có mốc thời gian nhưng không có giá trị" — kết quả thực tế tuỳ phiên bản, chưa verify hành vi ở trạng thái lệch này. An toàn nhất là purge theo prefix chung để cả hai key cùng bay.

Cách nữa, không cần framework hỗ trợ: single-flight bằng lock — chỉ một request được phép tính lại, phần còn lại đợi:

$value = Cache::get($key) ?? Cache::lock("lock:$key", 10)->block(5, function () use ($key) {
    return Cache::remember($key, 3600, fn () => $this->compute($key));
});

6.4. Cache tag: tưởng gọn, nhưng flush không như tên gọi

Nhiều người định dùng tag để dọn cache lúc rollback: gắn tag theo build, rollback thì Cache::tags(['build-42'])->flush(). Kiểm chứng thật:

Cache::put('doc:plain', 'v41', 600);
Cache::tags(['products'])->put('doc:tagged', 'v41', 600);

Cache::tags(['products'])->flush();

Cache::get('doc:plain');                    // string(3) "v41"   ← còn
Cache::tags(['products'])->get('doc:tagged'); // NULL            ← đã bay

Hoạt động đúng. Nhưng có ba hạn chế làm nó không hợp với bài toán này:

  1. Chỉ key nào được ghi qua tags() mới bị flush. Bất cứ chỗ nào trong code lỡ gọi Cache::put() trần đều lọt lưới — mà điều đó chắc chắn xảy ra trong một codebase có tuổi.
  2. Laravel dựng tag bằng một set phụ trong Redis, nghĩa là thêm key, thêm lệnh mỗi lần ghi, và có key rác cần flushStaleTags() dọn định kỳ.
  3. Memcached không hỗ trợ tag trong Laravel.

→ Tag hợp để dọn cache theo miền nghiệp vụ (xoá cache sản phẩm khi sửa sản phẩm), không hợp để dọn theo phiên bản deploy. Cho việc theo phiên bản, prefix vẫn đúng hơn: nó bao trọn mọi key, kể cả key viết ẩu.

6.5. Cache trong process không rollback nhưng cũng không cần

Một điểm nhẹ nhõm: opcache, compiled Blade view, config cache, route cache đều nằm trong image của từng colour. Rollback đổi container là chúng đổi theo, không có drift.

Với điều kiện không share thư mục đó giữa hai colour. Blade đặt tên file view đã biên dịch theo đường dẫn của file gốc — nếu hai colour cùng mount một volume vào storage/framework/views, colour này sẽ đọc phải markup đã biên dịch của colour kia, và đó là shape drift phiên bản HTML. Compose của blue-green phải để storage/framework nằm trong image, chỉ share storage/app (file người dùng upload).


7. Case thực tế và cách xử lý

Case 1 — 500 hàng loạt sau khi rollback "thành công"

Tình huống. 21:40 deploy build 42, có thay đổi DTO giá. 21:52 error rate tăng, rollback về build 41 lúc 21:55. nginx đã trỏ đúng, smoke test pass, nhưng error rate không giảm. Log đầy:

Uncaught Error: Typed property App\Support\Money::$scale
must not be accessed before initialization

Đọc dấu hiệu. Ba dấu hiệu cùng lúc chỉ đúng vào cache:

Xử lý, theo thứ tự:

  1. Khoanh vùng trước khi xoá. Đếm xem có bao nhiêu key nghi ngờ. Đây là bước hay bị bỏ, nhưng nó quyết định bước sau là "xoá vài trăm key" hay "làm trống cache của cả hệ thống":
    redis-cli --scan --pattern 'clicon-database-clicon_prod*price:*' --count 1000 | wc -l
  2. Xem thử một giá trị để chắc chắn đúng thủ phạm, đừng đoán:
    redis-cli --scan --pattern '*price:*' --count 100 | head -1 | xargs redis-cli GET
    # thấy: O:5:"Money":3:{...s:5:"scale";i:2;}   ← 3 property, đúng là bản của build 42
  3. Xoá bằng SCAN + UNLINK, phạm vi hẹp nhất có thể — chỉ tiền tố key liên quan, không phải cả cache:
    redis-cli --scan --pattern 'clicon-database-clicon_prod*price:*' --count 1000 \
      | xargs -r -n 500 redis-cli UNLINK
  4. Chạy lại lượt hai sau 30 giây. Vì app vẫn đang chạy, SCAN có thể bỏ sót (đã đo: 97/55.000). Lượt hai gần như luôn ra ít key hơn nhiều; nếu vẫn ra nhiều thì nghĩa là còn chỗ nào đó đang ghi shape mới — quay lại kiểm tra worker, vì worker có thể vẫn đang chạy image cũ.
  5. Theo dõi tải database trong 5 phút sau đó. Vừa xoá cache xong là lúc dễ có stampede nhất.

Việc phải làm sau sự cố (không phải "nếu có thời gian"): chuyển Money sang cache dạng array (cách C). Sự cố này sẽ lặp lại nguyên xi ở lần đổi DTO tiếp theo nếu chỉ dọn cache mà không sửa gốc.

Case 2 — giá sai âm thầm, không có lỗi nào

Tình huống. Build 42 đổi đơn vị tiền: price từ đồng sang xu (nhân 100), tên field giữ nguyên. Chạy 40 phút thì phát hiện, rollback. Không có lỗi 500, không có exception. Nhưng ba ngày sau kế toán báo có đơn hàng giá lệch 100 lần.

Vì sao rollback không cứu. Build 41 đọc key price:1 ra số 10000 và hiểu là 10.000 đồng, trong khi build 42 đã ghi 10000 với nghĩa 10.000 xu = 100 đồng. Cùng shape (int), khác nghĩa. Không có cơ chế kỹ thuật nào phát hiện được — cả cách B cũng không, vì shape không đổi.

Xử lý:

  1. Purge ngay theo pattern hẹp (*price:*) — bước này giống case 1.
  2. Quan trọng hơn: tìm các đơn hàng tạo trong khung 40 phút đó và đối chiếu lại với giá gốc trong database. Cache đã sạch, nhưng số sai đã ghi xuống database rồi — purge cache không sửa được đơn hàng đã tạo.
  3. Đối chiếu bằng dữ liệu, không bằng suy đoán: so orders.total với tổng order_items.qty * products.price tính lại từ nguồn.

Bài học đáng giá nhất của case này: khi đổi ý nghĩa của một giá trị được cache, phải đổi luôn tên của nó — priceprice_cents. Đổi tên biến lệch nghĩa (máy không bắt được) thành lệch shape (máy bắt được ngay). Đây là cùng một nguyên tắc với expand/contract trong migration database: không bao giờ đổi nghĩa tại chỗ, luôn thêm cái mới bên cạnh rồi bỏ cái cũ sau.

Case 3 — cache:clear làm sập môi trường bên cạnh

Tình huống. Staging lỗi sau deploy. Dev chạy php artisan cache:clear trong container staging cho nhanh. Vài giây sau, production báo lỗi hàng loạt.

Nguyên nhân. RedisStore::flush() gọi FLUSHDB (đã đọc source Laravel 12.64.0 ở mục 2.4) — lệnh này không quan tâm prefix. Staging và production cùng trỏ vào một Redis instance và cùng một db index, chỉ khác CACHE_PREFIX. Prefix tách được key khi đọc/ghi, nhưng không tách được gì trước FLUSHDB. Kèm theo: lệnh đó block Redis 312 ms (đo trên 400k key), đủ để mọi thứ dùng chung instance đó cùng nghẽn.

Xử lý ngay: không có gì để "sửa" — cache đã mất. Việc cần làm là chống stampede trong 5 phút tiếp theo: bật rate limit ở nginx nếu database đang quá tải, và theo dõi cho tới khi hit rate hồi.

Chống tái diễn, theo thứ tự ưu tiên:

  1. Tách db index theo môi trường — rẻ nhất, hiệu quả nhất, sửa một dòng env. Staging REDIS_CACHE_DB=17, production =7. Từ đó FLUSHDB ở staging không với tới production được. (Lưu ý: cách này không dùng được với Redis Cluster — chỉ có db 0.)
  2. Chặn lệnh trong production. Cache::flush() gần như không bao giờ là việc đúng ở production. Chặn bằng một dòng trong AppServiceProvider::boot():
    if (app()->isProduction()) {
        Artisan::command('cache:clear', fn () => $this->error(
            'Bị chặn ở production. Dùng scripts/purge-cache.sh <pattern>.'
        ));
    }
  3. Có sẵn lệnh đúng để thay thế — nếu chỉ chặn mà không đưa lựa chọn khác, người ta sẽ tìm cách vòng qua. Một script purge-cache.sh nhận pattern, in ra số key sẽ xoá và hỏi xác nhận trước khi chạy.

Case 4 — deploy thứ hai trong ngày làm Redis hết bộ nhớ

Tình huống. Đã áp dụng cách A (versioned prefix). Deploy build 42 lúc 10h, chưa kịp dọn prefix build 41. 14h deploy build 43. Redis bắt đầu trả:

OOM command not allowed when used memory > 'maxmemory'

Nguyên nhân. Ba thế hệ prefix sống chung. Đo thật: một thế hệ 200k key = 56,9 MB, hai thế hệ = 112,0 MB. Ba thế hệ ≈ 170 MB. Và maxmemory-policy mặc định là noeviction (đã verify), nghĩa là Redis không tự dọn key cũ — nó từ chối ghi. Session, lock, rate limiter dùng chung instance cùng chết theo.

Xử lý ngay: xoá thế hệ cũ nhất bằng SCAN + UNLINK (UNLINK giải phóng bộ nhớ ở luồng nền nên không làm tình hình nghẽn tệ thêm).

Chống tái diễn:

  1. maxmemory-policy allkeys-lru cho instance chỉ chứa cache. Lúc chật, Redis tự bỏ key ít dùng nhất thay vì từ chối ghi — cache miss thì tệ, nhưng chấp nhận được; từ chối ghi thì là sự cố.
  2. Không đặt allkeys-lru cho instance chứa session/lock/queue: LRU sẽ hồn nhiên xoá session của người dùng đang đăng nhập và xoá cả lock đang giữ. Đây chính là lý do phải tách db index hoặc tách instance ở mục 4.1.
  3. Dọn prefix cũ ngay trong bluegreen.sh: sau khi deploy build N thành công và đã chạy ổn định (ví dụ sau 30 phút, qua một job hẹn giờ), purge prefix của build N-2 — giữ lại N-1 để còn rollback được.

8. Câu hỏi phụ interviewer hay hỏi tiếp

"Rollback xong rồi thì ai xoá cache cũ?"
Không ai cả, trừ khi ta viết. Đó chính là vấn đề. Có ba lựa chọn: để TTL tự lo (chấp nhận lỗi kéo dài tới hết TTL), purge trong chính lệnh rollback (cách D), hoặc để cache cũ nằm đó vô hại vì đã tách namespace theo build (cách A).

"Vì sao không dùng KEYS cho nhanh?"
Redis chạy một luồng. Đo thật trên 200k key: KEYS giữ server 89,4 ms trong một lệnh duy nhất; SCAN + UNLINK mất tổng 11,2 giây nhưng lệnh chậm nhất chỉ 7,5 ms. KEYS nhanh hơn về tổng thời gian nhưng đánh đổi bằng việc treo toàn bộ client khác trong 89 ms — với 5.000 req/s là ~445 request bị kẹt cùng lúc.

"DEL hay UNLINK?"
UNLINK. Nó gỡ key khỏi keyspace ngay rồi giải phóng bộ nhớ ở luồng nền. Với value nhỏ thì gần như không khác; với hash/list lớn thì DEL block theo kích thước value, còn UNLINK thì không.

"php artisan cache:clear có an toàn không?"
Không. Với Redis store nó gọi FLUSHDB — đã đọc source Laravel 12.64.0. Nó bỏ qua prefix, xoá sạch db đó kể cả key của colour kia và của môi trường khác dùng chung db index, và block 312 ms trên 400k key. Ở production nên chặn hẳn.

"Redis Cluster thì khác gì?"
Cluster chỉ có một database (id 0), nên FLUSHDBFLUSHALL — không tách được môi trường bằng db index, chỉ còn prefix. SCAN phải chạy trên từng node, một lệnh không quét được cả cluster. Chưa verify trực tiếp, môi trường test là standalone.

"Vì sao không cho mỗi colour một Redis riêng cho sạch?"
Vì lúc switch thì cache của colour mới hoàn toàn trống → stampede đúng lúc deploy, mà không được lợi ích nào so với versioned prefix. Cộng thêm tiền một instance nữa và cache hai bên không nhất quán. Chỉ hợp khi cache là loại nạp sẵn được trước lúc switch.

"Cache trống sau purge thì chống stampede thế nào?"
Hai cách, không loại trừ nhau. Cache::flexible($key, [tươi, hết hạn], $fn) — stale-while-revalidate, trả bản cũ ngay và tính lại ở nền (có từ Laravel 11.23, đã verify trên 12.64). Hoặc single-flight bằng Cache::lock — chỉ một request được tính lại, phần còn lại chờ. Lưu ý flexible() ghi hai key: key chính và illuminate:cache:flexible:created:<key>, script purge phải bắt cả hai.

"Dùng cache tag để dọn theo build được không?"
Được về mặt kỹ thuật (đã verify tags()->flush() chỉ xoá key có tag, không đụng key khác), nhưng không nên: chỉ key được ghi qua tags() mới bị dọn, mọi Cache::put() trần đều lọt lưới. Prefix bao trọn hơn.

"Memcached thì làm sao, nó không có SCAN?"
Đúng, và đã verify: delete app:v41:* trả NOT_FOUND — nó hiểu dấu * theo nghĩa đen. Chỉ có flush_all (xoá sạch, có kèm hẹn giờ, đã verify với delay 2 giây). Cách thay thế là generation counter: một key đếm quyết định namespace, deploy thì incr, rollback thì decr — đã verify dữ liệu generation cũ vẫn nguyên sau khi decr.

"Session có bị ảnh hưởng không?"
Có, nếu version-hoá prefix session — toàn bộ user bị logout mỗi lần deploy. Không bao giờ làm thế. Session phải nằm ở connection/db riêng, và policy eviction của instance đó không được là allkeys-lru, nếu không Redis sẽ xoá session của người đang dùng khi bộ nhớ chật.

"Queue có cùng vấn đề không?"
Có, và ít người nghĩ tới. Job payload cũng là object serialize. Rollback làm worker lùi về code cũ trong khi hàng đợi còn job do code mới đẩy vào → job failed hàng loạt. Cách chống giống hệt: job chỉ nhận id, không nhận model — new SendInvoice($invoice->id).

"Nếu chỉ được làm một việc thì làm gì?"
Bỏ việc cache PHP object. Cache array/JSON thuần và dựng object lại lúc đọc. Nó xoá hẳn cả lớp lỗi fatal thay vì giảm rủi ro, không tốn RAM, không thêm bước deploy, và không phụ thuộc vào việc ai đó nhớ chạy đúng lệnh lúc 3 giờ sáng.