Feature Flags & Progressive Rollout: deploy tính năng mới mà không sợ vỡ trận — hoatq.dev

cat blog/.md

Feature Flags & Progressive Rollout: deploy tính năng mới mà không sợ vỡ trận

date: tags: backend, devops, release-management, design-pattern, production

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ạiTuổi thọVí dụ
Release flagVài ngày – vài tuầnBật tính năng mới
Experiment flagVài tuầnA/B test giao diện checkout
Ops flag (kill switch)Dài hạnTắt khẩn cấp tính năng khi BE quá tải
Permission flagVĩnh viễnTí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:

  1. 0% — Deploy code lên production, flag OFF. Verify không có regression nào.
  2. Internal only — Bật cho user_overrides chứa team nội bộ. Eat your own dog food trong 1–2 ngày.
  3. 1% — Bật cho 1% user thật. Theo dõi error rate, latency, business metric. Tối thiểu 1 giờ.
  4. 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.
  5. 50% — Đủ lớn để so sánh A/B nghiêm túc.
  6. 100% — Full rollout.
  7. 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.

// reactions


cat comments.log


hoatq@dev : ~/blog $