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
KEYSduyệ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:
- Code: build 41 (cũ).
- Database schema: build 42 (migration đã chạy, thường không rollback — xem migration PostgreSQL zero-downtime).
- Cache: lẫn lộn — key cũ do build 41 ghi, key mới do build 42 ghi, nằm chung một chỗ, không phân biệt được.
Cache là chỗ tệ nhất trong ba cái, vì hai lý do:
- 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.
- 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ểu | Biểu hiện sau rollback | Mức |
|---|---|---|---|
| 1 | Shape drift — cache object đã serialize, class đổi property | Fatal error, HTTP 500 lặp lại | Nặng nhất |
| 2 | Semantic 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 |
| 3 | Poisoned value — build mới có bug tính sai, kết quả sai đã nằm trong cache | Rollback code xong vẫn sai | Nặng |
| 4 | Key drift — build mới đổi cách sinh key | Chỉ là cache miss, tự tính lại | Nhẹ, 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-policytrả vềnoeviction) thì khi chạmmaxmemory, Redis sẽ từ chối mọi lệnh ghi với lỗiOOM 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
maxmemoryvà đổi policy sangallkeys-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(LaravelCache::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ấmCache::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
pricetừ đồng sang xu mà shape vẫn làint, việc bumpSHAPElạ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 (price→price_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ách | Tổng thời gian | Lệnh chậm nhất (block server) | Số lệnh > 1ms |
|---|---|---|---|
KEYS 'prefix:*' | 0,35 s | 89,4 ms (một cú duy nhất) | 1 |
SCAN COUNT 1000 + UNLINK theo lô 500 | 11,24 s | 7,5 ms | 128 |
Đọ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 : 9797 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;
}
FLUSHDB — bỏ 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ách | Mấ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 state | Rollback có ảnh hưởng? | Ghi chú |
|---|---|---|
Cache (Cache::) | Có — cả 4 kiểu ở mục 1.3 | Chủ đề chính của bài |
| Session | Có, nếu đổi prefix theo build → toàn bộ user bị logout | Không bao giờ version-hoá prefix session |
| Queue (nếu dùng Redis queue) | Có — job payload là object serialize, y hệt shape drift | Job do green đẩy vào, blue lôi ra chạy |
Cache lock (Cache::lock) | Ít — TTL ngắn, tự hết | Trừ khi lock forever |
| Rate limiter | Ít — đổi prefix chỉ làm reset bộ đếm | Reset 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
FLUSHDBvàFLUSHALLlà một. Ở cluster, tách bằng prefix là cách duy nhất, vàSCANphả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 prefix | B. Envelope version | C. Không cache object | D. Purge lúc rollback | |
|---|---|---|---|---|
| Sửa ở đâu | Hạ tầng / script deploy | Code (một helper) | Code (nhiều chỗ) | Runbook / script vận hành |
| Thêm RAM | ×2 | Không | Giảm nhẹ | Không |
| Cache sau rollback | Ấm | Lạnh ở key đổi shape | Ấm | Lạnh toàn bộ |
| Cần thao tác tay lúc rollback | Không | Không | Không | Có |
| Chặn shape drift (kiểu 1) | Có | Có | Chặn tận gốc | Có, nếu purge đúng pattern |
| Chặn semantic drift (kiểu 2) | Có | Chỉ khi nhớ bump | Không | Có |
| Chặn poisoned value (kiểu 3) | Có | Không | Không | Có |
| Rủi ro con người | Thấ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
- Luôn làm C trước. Nó không phải một "phương án", nó là sửa lỗi. Ba phương án còn lại đều là cách sống chung với một quyết định thiết kế tồi. Nếu chỉ có thời gian làm một việc, làm C.
- Thêm A khi RAM Redis còn dư ≥ 2,5 lần mức dùng và deploy không quá dày (≤ vài lần/ngày). A là cách duy nhất giữ cache ấm qua rollback, rất đáng nếu đang có key nặng (tính từ nhiều bảng, mất hàng giây để dựng lại).
- Thêm B thay cho A khi RAM chật hoặc khi deploy liên tục (trunk-based, chục lần/ngày) — vì với A thì mỗi lần deploy là một lần cache trống, chục lần/ngày là không chịu nổi.
- D luôn phải có sẵn dưới dạng script đã viết và đã test, kể cả khi đã làm A/B/C. Nó là cái búa phá kính lúc khẩn cấp. Điều kiện: script viết sẵn, chạy thử trên staging ít nhất một lần, tham số pattern có giá trị mặc định an toàn. Không được là câu lệnh gõ tay lúc đang cháy.
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ệc | Redis | Memcached 1.6.45 |
|---|---|---|
| Xoá theo prefix | SCAN + UNLINK | Không có |
| Xoá sạch | FLUSHDB / FLUSHDB ASYNC | flush_all [delay] |
| Hẹn giờ xoá | Không (phải tự làm) | Có — flush_all <giây> |
| Tách namespace | Prefix, hoặc db index (trừ Cluster) | Chỉ prefix + generation counter |
| Cache tags | Có (Laravel dựng bằng set) | Không hỗ trợ trong Laravel |
| Xoá không block | UNLINK | Khô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:
- Đếm cache miss theo lý do. Với cách B thì phân biệt được "miss vì hết TTL" và "miss vì lệch version" — cái thứ hai tăng vọt sau rollback là bằng chứng cache đang bị nhiễm. Không có cách B thì không có tín hiệu này.
- Đếm
__PHP_Incomplete_Classtrong log. Grep một chuỗi cố định, rẻ, và bắt được đúng trường hợp C ở mục 1.4 vốn không ném exception. used_memoryvàDBSIZEtheo prefix. Nếu dùng cách A, hai số này phải quay về mức cũ sau vài giờ. Nếu chúng cứ leo, nghĩa là prefix cũ không được dọ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:
- 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".
xargs -r— không có-rthì khi không tìm thấy key nào,xargsvẫn gọiredis-cli UNLINKkhông tham số và trả về lỗi, làm script rollback fail dù rollback đã thành công.- 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:
- Chỉ key nào được ghi qua
tags()mới bị flush. Bất cứ chỗ nào trong code lỡ gọiCache::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. - 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ỳ. - 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:
- Lỗi xảy ra ở build 41 nhưng nói về một property build 41 có khai báo → giá trị đến từ nơi khác, không phải từ code.
- Error rate không phải 100% mà khoảng 30–60%, dao động → chỉ những request trúng key đã bị green ghi mới lỗi.
- Bấm F5 nhiều lần: lúc lỗi lúc không, nhưng cùng một sản phẩm thì luôn lỗi → khoá theo key, đúng đặc trưng cache.
Xử lý, theo thứ tự:
- 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 - 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 - 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 - 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ũ.
- 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ý:
- Purge ngay theo pattern hẹp (
*price:*) — bước này giống case 1. - 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.
- Đối chiếu bằng dữ liệu, không bằng suy đoán: so
orders.totalvới tổngorder_items.qty * products.pricetí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ó —
price → price_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:
- 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.) - 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 trongAppServiceProvider::boot():if (app()->isProduction()) { Artisan::command('cache:clear', fn () => $this->error( 'Bị chặn ở production. Dùng scripts/purge-cache.sh <pattern>.' )); } - 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.shnhậ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:
maxmemory-policy allkeys-lrucho 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ố.- Không đặt
allkeys-lrucho 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. - 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 FLUSHDB ≡ FLUSHALL —
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.