cat blog/.md
Feature Flags & Progressive Rollout: deploy tính năng mới mà không sợ vỡ trận
Có một lần tôi push một feature lớn — thuật toán tính cước vận chuyển mới — vào production thứ Sáu chiều (vâng, đừng hỏi tại sao). Code đã qua review, đã pass test, đã smoke test trên staging. Nhưng trong production, dữ liệu thật có những edge case mà fixture test không có. 15 phút sau khi deploy, số đơn hàng có cước phí âm bắt đầu xuất hiện.
Tôi có hai lựa chọn: revert deploy (mất 10 phút build lại + thêm rủi ro lúc rollback database migration), hoặc… không có lựa chọn nào khác. Vì lúc đó hệ thống chưa có feature flag.
Sau sự cố đó, mọi tính năng lớn đều được wrap trong flag. Deploy code lên prod tách hẳn khỏi release feature. Code nằm im trong codebase, chỉ bật cho 1% user nội bộ, theo dõi metric, rồi mới mở rộng. Có sự cố? Tắt flag, 100ms sau hệ thống quay lại hành vi cũ. Không cần revert, không cần rollback DB.
Bài này là những gì tôi đúc kết được khi làm với feature flag trong vài năm qua.
Tách deploy ra khỏi release
Đây là tư duy quan trọng nhất:
- Deploy = đưa code lên server. Cơ học, không tác động người dùng.
- Release = bật tính năng cho người dùng thấy. Quyết định, không liên quan tới việc code đã chạy ở đâu.
Không có flag thì deploy = release: merge PR là user thấy ngay. Có flag thì deploy có thể xảy ra trước hàng tuần, còn release thì được kiểm soát bởi một dòng cấu hình.
Lợi ích đi kèm:
- Trunk-based development: không cần long-lived branch. Code chưa xong vẫn merge được, ẩn sau flag mặc định OFF.
- Canary release: bật cho 1% → 10% → 50% → 100%, theo dõi error rate ở từng nấc.
- Kill switch: tắt feature tức thì khi có sự cố, không phải đi rollback.
- A/B testing: chia user thành hai nhóm, đo metric.
- Per-user override: bật cho team nội bộ test trước, hoặc bật cho một khách hàng cụ thể.
Các loại flag (đừng trộn lẫn)
Đây là cái mà nhiều team làm sai: dùng một loại flag cho mọi thứ, dẫn đến nợ kỹ thuật chồng chất.
| Loại | Tuổi thọ | Ví dụ |
|---|---|---|
| Release flag | Vài ngày – vài tuần | Bật tính năng mới |
| Experiment flag | Vài tuần | A/B test giao diện checkout |
| Ops flag (kill switch) | Dài hạn | Tắt khẩn cấp tính năng khi BE quá tải |
| Permission flag | Vĩnh viễn | Tính năng chỉ dành cho gói Enterprise |
Quan trọng: release flag và experiment flag phải có expiry date. Sau khi feature đã 100% rollout, xoá flag và code path cũ. Nếu không, codebase sẽ thành rừng if (flag) lồng nhau, mỗi branch là một combinatorial test case.
Tôi áp dụng quy tắc: mọi release flag đều có một ticket “Cleanup flag X” trong backlog, due 30 ngày sau khi rollout xong.
Một implementation tối giản với Postgres
Không phải team nào cũng cần LaunchDarkly. Với startup nhỏ, một bảng Postgres + cache Redis là đủ. Đây là schema tôi từng dùng cho một service mid-scale (10k req/s):
CREATE TABLE feature_flags (
name TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
rollout_pct INTEGER NOT NULL DEFAULT 0 CHECK (rollout_pct BETWEEN 0 AND 100),
user_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
user_overrides chứa {"user_id_123": true, "user_id_456": false} — dùng để bật/tắt cho người dùng cụ thể, vượt lên trên rollout_pct.
Logic đánh giá:
import hashlib
from dataclasses import dataclass
@dataclass
class Flag:
name: str
enabled: bool
rollout_pct: int
user_overrides: dict
def is_enabled(flag: Flag, user_id: str) -> bool:
# 1. Master switch off → không cần xét gì nữa
if not flag.enabled:
return False
# 2. Override cho user cụ thể → ưu tiên cao nhất
if user_id in flag.user_overrides:
return flag.user_overrides[user_id]
# 3. Rollout theo phần trăm, ổn định (sticky)
if flag.rollout_pct >= 100:
return True
if flag.rollout_pct <= 0:
return False
bucket = bucket_for(flag.name, user_id)
return bucket < flag.rollout_pct
def bucket_for(flag_name: str, user_id: str) -> int:
"""
Hash user_id + flag_name → số 0–99.
Cùng user, cùng flag → cùng bucket → trải nghiệm nhất quán.
"""
h = hashlib.md5(f"{flag_name}:{user_id}".encode()).hexdigest()
return int(h[:8], 16) % 100
Hai điểm đáng chú ý:
1. Sticky bucketing. Đừng random mỗi request, vì user sẽ thấy UI nhảy múa. Hash user_id để mỗi user luôn rơi vào cùng bucket cho cùng flag. Cùng cách Stripe và LaunchDarkly làm.
2. Tại sao hash flag_name chung với user_id? Nếu chỉ hash user_id, user nào “xui” sẽ luôn rơi vào nhóm 0–10% và thấy mọi feature mới. Trộn flag_name vào để mỗi flag có một phân phối độc lập.
Cache để không bắn DB mỗi request
Nếu mỗi API call đều SELECT * FROM feature_flags WHERE name = ?, bạn đang tự bắn vào chân mình. Cache aggressively:
import time
from threading import Lock
_cache: dict[str, tuple[Flag, float]] = {}
_lock = Lock()
TTL_SECONDS = 30
def get_flag(name: str) -> Flag:
now = time.time()
cached = _cache.get(name)
if cached and now - cached[1] < TTL_SECONDS:
return cached[0]
with _lock:
# Double-check sau khi giành lock
cached = _cache.get(name)
if cached and now - cached[1] < TTL_SECONDS:
return cached[0]
flag = load_from_db(name)
_cache[name] = (flag, now)
return flag
TTL 30 giây nghĩa là kill switch có độ trễ tối đa 30s trước khi propagate hết các instance. Với hầu hết case là chấp nhận được. Nếu cần nhanh hơn, kết hợp với Redis pub/sub: khi flag thay đổi, publish event để mọi instance invalidate cache ngay.
Quy trình rollout an toàn
Khi đã có sẵn flag, đây là playbook tôi áp dụng cho mọi feature lớn:
- 0% — Deploy code lên production, flag OFF. Verify không có regression nào.
- Internal only — Bật cho
user_overrideschứa team nội bộ. Eat your own dog food trong 1–2 ngày. - 1% — Bật cho 1% user thật. Theo dõi error rate, latency, business metric. Tối thiểu 1 giờ.
- 10% — Nếu metric ổn, mở rộng. Tối thiểu vài giờ ở nấc này, qua một chu kỳ traffic peak.
- 50% — Đủ lớn để so sánh A/B nghiêm túc.
- 100% — Full rollout.
- Cleanup — Sau 1–2 tuần ổn định, xoá flag và code path cũ. Cleanup ngay khi còn nhớ, đừng để dồn.
Ở mỗi nấc, có hai metric tôi luôn nhìn: error rate của endpoint liên quan, và business metric trực tiếp (conversion, GMV, retention). Code không lỗi không có nghĩa là feature đang hoạt động đúng.
Anti-pattern nên tránh
- Flag trong vòng lặp nóng. Đánh giá flag là rẻ, nhưng không miễn phí. Lấy flag value một lần ở đầu request, dùng lại bên trong.
- Logic nested 3+ tầng flag. Nếu một code path phụ thuộc vào 3 flag cùng lúc, bạn không thể test hết. Hợp nhất hoặc tách feature.
- Không log flag value vào trace. Khi debug một incident, biết user đang thấy variant nào là cực quan trọng. Inject vào structured log và OpenTelemetry span attribute.
- Flag “tạm thời” sống mãi. Cứ 3 tháng audit lại: flag nào đã 100% trên 30 ngày, ra lệnh xoá.
Khi nào cần nâng cấp lên service riêng
Postgres + Redis đủ cho hầu hết case. Bạn nên cân nhắc service như LaunchDarkly, Unleash, hay Flagsmith khi:
- Cần update flag không cần deploy backend (UI cho PM tự bật/tắt).
- Cần audit log đầy đủ ai bật/tắt flag, lúc nào.
- Cần targeting rule phức tạp (theo country, tier, cohort).
- Cần SDK client-side cho mobile/web với streaming update.
Đến lúc đó, đừng tự viết — chi phí maintain không đáng so với mua.
Kết
Feature flag không phải silver bullet, nhưng là một trong những công cụ release management có ROI cao nhất tôi từng dùng. Chi phí setup ban đầu thấp, lợi ích nhân lên theo mỗi feature lớn deploy về sau. Quan trọng hơn cả: nó cho phép bạn ngủ ngon hơn vào tối thứ Sáu — vì biết rằng nếu có gì lệch, bạn không cần wake up cả team để rollback, chỉ cần flip một switch.
Nếu bạn chưa có flag system, bắt đầu nhỏ: một bảng, một cache, một function is_enabled. Đừng đợi đến lúc cần kill switch mới đi viết.
cat comments.log