Tháng trước, lúc 2 giờ sáng, PagerDuty reo. Error rate checkout tăng gấp 5 lần trong 10 phút. On-call mở dashboard, thấy spike ở service payment. Mở trace, mọi request đều đi vào nhánh code mới mà team đã merge tuần trước nhưng flag vẫn để OFF. Hoá ra có người bật flag trên staging chiều thứ Sáu, nhưng config provider đồng bộ giữa staging và production vì namespace chưa tách đúng. Flag ON trên staging đồng nghĩa flag ON trên production, cho 100 % traffic, lúc nửa đêm không ai để ý.
Incident đó không phải do code mới có bug, code đã qua QA đầy đủ. Nó do quản lý flag: ai có quyền bật, config propagate đi đâu, và không ai verify trạng thái flag trước lúc merge. Incident đó làm rõ một điều: nếu feature flag chỉ là if (flag) { ... } thì bạn đang dùng nó như công tắc đèn. Trên production, flag thực chất là chính sách phân phối hành vi, có hiệu lực theo thời gian, theo user, theo môi trường, và quan trọng nhất: có blast radius khi sai.
Bài này không lặp lại phần “flag vs config runtime” (đã có bài riêng); thay vào đó đặt flag trong khung taxonomy + lifecycle + governance, những thứ quyết định flag sẽ là công cụ hay sẽ là nguồn incident. Góc nhìn ở đây là engineer middle+ đã triển khai flag thật, từng thấy “flag hell” hoặc từng dính incident do rollout.
Ba họ flag, và vì sao phân loại quan trọng
Không phải mọi flag đều giống nhau. Một flag bật tính năng mới cho 5 % user khác hoàn toàn với một flag tắt khẩn cấp endpoint nặng khi database overload. Nếu bạn quản lý cả hai theo cùng quy trình, bạn sẽ hoặc quá cẩn thận với flag nhỏ (chậm ship), hoặc quá lỏng với flag nguy hiểm (gây incident). Phân loại giúp gán đúng lifecycle, đúng owner, và đúng mức kiểm soát cho từng flag.
Release flag
Đây là loại phổ biến nhất. Mục đích: tách triển khai khỏi bật tính năng, deploy code lên production nhưng chưa ai thấy tính năng mới cho đến khi flag bật. Điều này cho phép team merge code liên tục vào main mà không cần chờ feature hoàn chỉnh, giảm conflict nhánh dài ngày.
Release flag có một invariant cốt lõi mà rất nhiều team vi phạm: flag phải có ngày chết (sunset date). Khi tính năng đã ổn định trên 100 % traffic, flag và nhánh code cũ phải được xoá. Nghe hiển nhiên, nhưng thực tế thì release flag là loại hay bị “quên” nhất. Merge xong, bật 100 %, mọi người chuyển sang feature tiếp theo, không ai tạo ticket xoá flag. Sau 6 tháng, codebase có 47 flag mà không ai nhớ flag nào còn cần.
Rủi ro hệ thống của release flag nằm ở nhánh code song song lâu ngày. Khi flag tồn tại, codebase có hai đường đi cho cùng một flow: đường cũ (flag OFF) và đường mới (flag ON). Mỗi lần sửa bug ở flow đó, developer phải sửa cả hai nhánh, hoặc sót một nhánh và tạo bug chỉ lộ cho subset user. Test matrix cũng phình to: mỗi flag nhân đôi số trạng thái cần test.
Experiment flag
Experiment flag phục vụ A/B test hoặc đo hành vi theo cohort. Nó gắn chặt với analytics, conversion rate, latency, engagement metric, và thường cần cohort sticky (cùng user luôn thấy cùng variant trong suốt thời gian thử nghiệm).
Invariant quan trọng nhất của experiment flag: cohort phải ổn định và có kiểm soát, và metric phải gắn version của thử nghiệm. Hay gặp tình huống team chạy A/B test hai tuần, tuần thứ ba sửa copy nút CTA của variant B nhưng không tạo experiment version mới. Kết quả: dashboard gộp data B-v1 và B-v2 làm một, PM kết luận “B thắng” trong khi thực tế hiệu ứng đến từ thay đổi copy tuần 3, không phải thiết kế gốc. Mọi experiment cần dimension “schema/version” trong telemetry, sửa bất kỳ biến nào trong experiment thì phải tạo version mới.
Rủi ro khác của experiment flag là peeking: nhìn kết quả sớm rồi dừng test khi thấy “số đẹp”. Thống kê yêu cầu sample size đủ lớn; dừng sớm dễ cho kết luận sai. Đây không phải vấn đề kỹ thuật mà là vấn đề quy trình, nhưng engineer cần hiểu để push back khi PM muốn kết thúc test sớm.
Ops flag (kill-switch)
Kill-switch là flag dùng để giảm áp lực hệ thống khi downstream sập hoặc có bug nghiêm trọng. Ví dụ: DISABLE_HEAVY_REPORT tắt endpoint tổng hợp báo cáo nặng khi database đang overload, hoặc FALLBACK_PAYMENT_GATEWAY chuyển traffic sang gateway dự phòng khi gateway chính timeout.
Invariant cốt lõi: đường dẫn đánh giá flag phải rẻ và ít phụ thuộc. Nếu kill-switch cần gọi API của flag provider để biết trạng thái, mà flag provider cũng đang chậm vì cùng nguyên nhân gây incident, thì kill-switch vô dụng. Kill-switch nên có giá trị mặc định an toàn khi không đọc được config, fail-open (mặc định bật tính năng) hay fail-closed (tắt) là quyết định sản phẩm, không chỉ kỹ thuật.
Kill-switch cũng cần runbook rõ ràng: ai có quyền bật, trong điều kiện nào, bật bao lâu, rollback thế nào. Hay gặp tình huống kill-switch tồn tại nhưng chỉ 2 người biết nó ở đâu trong config, khi incident xảy ra lúc 3 giờ sáng, on-call không biết cách bật. Kill-switch không có runbook thì chỉ là biến boolean trong config.
Vòng đời flag: tạo, rollout, retire
Mỗi flag đi qua ba giai đoạn, và phần lớn vấn đề xảy ra khi team chỉ tập trung vào giai đoạn giữa (rollout) mà bỏ qua hai giai đoạn còn lại.
Tạo flag, checklist trước khi viết code
Trước khi tạo flag mới, nên hỏi ba câu. Thứ nhất: flag này thuộc họ nào? Release, experiment, hay ops? Câu trả lời quyết định ai sẽ own flag, metric nào cần theo dõi, và TTL (time-to-live) hợp lý là bao lâu. Thứ hai: giá trị mặc định trong production là gì, OFF hay ON? Điều này ảnh hưởng trực tiếp đến hành vi hệ thống khi flag provider chậm hoặc timeout. Thứ ba: ai có quyền thay đổi trạng thái flag trong production? “Mọi người” không phải câu trả lời, cần ít nhất RBAC cơ bản.
Naming convention cũng nên chốt từ lúc tạo. Prefix theo domain (billing_, checkout_, ops_) giúp grep và audit. Tránh tên mơ hồ kiểu new_ui, sau 6 tháng không ai biết “new” là gì. Tốt hơn: checkout_redesign_2024q2 hoặc billing_fallback_stripe.
Rollout, phần trăm dần và canary
Rollout không nên là bật 0 % → 100 % trong một bước. Pattern an toàn nhất hiệu quả: bắt đầu 1-5 % traffic, theo dõi error rate và latency theo cohort (không chỉ toàn site), tăng dần lên 10 %, 25 %, 50 %, 100 %. Mỗi bước giữ ít nhất vài giờ đến một ngày tuỳ mức độ rủi ro.
Canary theo tenant hoặc region hữu ích khi bug chỉ lộ ở subset dữ liệu. Ví dụ: tính năng mới xử lý đa tiền tệ có thể chạy tốt cho USD nhưng break cho VND vì format số khác. Bật cho region Việt Nam trước (blast radius nhỏ, dễ monitor) rồi mở rộng.
Trong suốt rollout, cần dashboard riêng cho flag đang rollout, không phải dashboard chung của service. Dashboard này show metric chia theo variant (flag ON vs OFF): error rate, latency P95, conversion (nếu là experiment). Nếu metric variant ON tệ hơn, rollback bằng cách đặt flag về OFF, đây là giá trị cốt lõi của flag: rollback không cần deploy.
Retire, phần hay bị quên nhất
Đây là nơi nợ kỹ thuật tích tụ. Mỗi flag tồn tại là thêm một nhánh code, thêm một trạng thái cần test, thêm một thứ developer mới phải hiểu. Số flag tăng thì độ phức tạp nhận thức tăng, mỗi lần refactor phải “đoán” hành vi vì không biết flag nào đang ON cho ai.
Quy tắc thực dụng: mỗi sprint có quota xoá flag, tối thiểu 2 release flag đã ổn định 100 % phải được retire. Cụ thể retire nghĩa là: xoá nhánh code if (!flag), xoá flag khỏi config provider, xoá dashboard rollout. Flag release tồn tại hơn 30 ngày ở trạng thái 100 % ON mà chưa có ticket retire → tự động tạo ticket bắt buộc.
Luồng retire đầy đủ trông thế này:
Nếu flag có coupling với flag khác (sẽ nói ở phần sau), retire phải coordinate, xoá flag A trước khi xoá flag B có thể tạo trạng thái không hợp lệ.
Cache, stale, và bài toán propagation
Khi bạn đổi trạng thái flag, bao lâu thì toàn bộ hệ thống thấy giá trị mới? Câu trả lời không phải “ngay lập tức”, và khoảng cách giữa “toggle” và “toàn bộ hệ thống áp dụng” là nguồn incident phổ biến.
Nơi flag được đánh giá
Server-side evaluation: app gọi SDK của flag provider, SDK đọc config từ provider (hoặc cache local), trả về giá trị. Ưu điểm là dễ audit (mọi evaluation trên server, có log), nhất quán (mọi request đi qua cùng logic). Nhược điểm là phụ thuộc latency tới provider, nếu provider chậm, mỗi request chậm thêm.
Client-side evaluation (browser, mobile, edge): SDK chạy trên client, thường nhận config bundle từ server khi init rồi cache local. Ưu điểm là phản hồi nhanh (không cần gọi server mỗi lần check flag). Nhược điểm là khó đảm bảo mọi client cùng phiên bản config, user không refresh tab trong 3 ngày vẫn dùng config cũ.
Các lớp cache có thể giữ giá trị cũ
Khi bạn toggle flag, giá trị mới phải “chảy” qua nhiều lớp trước khi đến user. Mỗi lớp có thể cache giá trị cũ:
CDN hoặc service worker precache giữ HTML hoặc JS bundle chứa config cũ. In-process cache trên app server, nhiều SDK flag cache config trong bộ nhớ với TTL, nếu TTL là 5 phút thì sau khi toggle, vẫn có server serve giá trị cũ trong tối đa 5 phút. Mobile app bundle cũ, user không update app thì flag config trong app vẫn là version cũ.
Một incident điển hình: ops bật DISABLE_HEAVY_REPORT trên server nhưng CDN vẫn cache HTML cũ chứa JS gọi endpoint nặng. 5 % user vẫn hammer endpoint vì client cũ, kill-switch “đã bật” nhưng vẫn bị tải. Giải pháp: thêm 429 + Retry-After ở gateway level như lớp an toàn cuối, không phụ thuộc hoàn toàn vào flag propagation.
SLO cho propagation
Mỗi loại flag cần SLO propagation khác nhau. Kill-switch cần propagate trong giây, không phải phút, nếu bạn bật kill-switch mà 5 phút sau mới có hiệu lực thì 5 phút downtime đó có thể là thảm hoạ. Release flag rollout có thể chấp nhận vài phút delay, không ai chết vì feature mới bật chậm 3 phút.
Để đo propagation time: ghi timestamp lúc toggle, ghi timestamp lúc SDK trên từng server nhận giá trị mới, diff là propagation latency. Đặt alert khi propagation vượt SLO. Đây là metric ít team đo nhưng rất có giá trị khi debug incident liên quan flag.
Governance, quy ước theo quy mô team
Governance flag không phải “giấy tờ”, nó là cách giữ entropy dưới ngưỡng mà team vẫn ship được. Mức độ governance cần tỷ lệ thuận với quy mô team và số flag.
Team nhỏ (1 team, < 10 người)
Ở quy mô này, governance tối giản là đủ: naming convention (prefix theo domain), giá trị mặc định an toàn (fail-closed cho kill-switch, OFF cho release flag mới), và checklist retire trong definition of done. Một file markdown trong repo liệt kê tất cả flag đang active, owner, ngày tạo, sunset date dự kiến, đơn giản nhưng hiệu quả.
Ở startup 8 người, flag management là một sheet Google Sheets mà mọi người cập nhật khi tạo hoặc retire flag. Không fancy nhưng hoạt động tốt vì mọi người ngồi cùng phòng, flag count chưa bao giờ quá 15.
Team vừa (2-5 team, 10-50 người)
Bắt đầu cần catalog flag có cấu trúc hơn: mỗi flag có owner (team hoặc cá nhân), mỗi flag có policy rõ ràng “ai có quyền thay đổi trạng thái trong production”. Nếu dùng flag provider (LaunchDarkly, Unleash, Flagsmith), RBAC theo environment là bắt buộc, staging và production phải tách namespace hoàn toàn (nhớ incident mở đầu bài?).
Quy tắc “ai merge được default prod” quan trọng ở quy mô này. Developer có thể tạo flag và set giá trị cho dev/staging. Nhưng thay đổi giá trị production cho flag ảnh hưởng > 10 % traffic cần approval từ tech lead hoặc SRE, tương tự như code review nhưng cho config.
Tổ chức lớn (nhiều team, nhiều service)
Ở quy mô này, cần platform evaluate flag chuẩn hoá: SDK chung cho mọi service, schema versioning cho flag config, runbook kill-switch có sẵn cho on-call, và dashboard cardinality theo flag (bao nhiêu flag đang active, bao nhiêu quá sunset date, bao nhiêu không có owner).
Quan trọng nhất ở quy mô lớn: audit log structured. Mọi thay đổi trạng thái flag phải được ghi lại: flag key, giá trị cũ → mới, ai thay đổi, lý do (reason code bắt buộc). Incident hay gặp ở tổ chức lớn: “không ai thừa nhận đã bật flag này”, audit log giải quyết tranh cãi đó trong 30 giây.
Không nhất thiết phải mua công cụ đắt tiền ngay; nhưng thiếu quy ước thì công cụ nào cũng chỉ là UI cho nợ kỹ thuật.
Vì sao flag là rủi ro hệ thống
Nhìn từ góc kỹ thuật, mỗi flag thay đổi tập hợp trạng thái mà hệ thống có thể đạt được. Khi bạn có 5 flag boolean độc lập, tổ hợp lý thuyết là 2^5 = 32 trạng thái. Thực tế không phải mọi tổ hợp đều xảy ra vì nhiều flag tương quan, nhưng vẫn đủ để QA “không biết user đang ở cấu hình nào”.
Hệ quả thứ nhất là chi phí kiểm thử tổ hợp. Mỗi flag thêm vào là thêm nhánh cần test. Nếu flag A ảnh hưởng checkout và flag B cũng ảnh hưởng checkout, bạn cần test ít nhất 4 tổ hợp: (A ON, B ON), (A ON, B OFF), (A OFF, B ON), (A OFF, B OFF). Với 10 flag ảnh hưởng cùng flow, test matrix trở nên bất khả thi nếu không có chiến lược ưu tiên.
Hệ quả thứ hai là chi phí nhận thức khi debug. Log cùng một request_id nhưng hai flag khác nhau có thể dẫn tới hai stack lỗi hoàn toàn khác. Engineer debug phải kiểm tra trạng thái flag tại thời điểm request, nếu flag evaluation không được log cùng request context (trace_id, flag_key, flag_value), debug trở thành đoán mò.
Hệ quả thứ ba là phụ thuộc thời gian. “Hôm qua chạy tốt” không còn đúng nếu flag đổi lúc nửa đêm. Khi review incident, timeline phải bao gồm cả “flag changes”, đây là thông tin mà nhiều team không ghi vào incident timeline, dẫn đến root cause analysis sai.
Governance không phải “thêm giấy tờ”. Nó là cách giữ entropy dưới ngưỡng, để hệ thống vẫn hiểu được (debuggable) và kiểm thử được (testable) khi số flag tăng.
Ba kịch bản thực tế
Release flag quên retire
Team A bật NEW_CHECKOUT cho 100 % traffic sau hai tuần canary. Mọi metric tốt, team chuyển sang feature tiếp theo. Một tháng sau, nhánh if (!flag) vẫn còn trong code vì “sợ cần rollback”. Khi có bug thuế ở checkout, developer sửa nhánh mới nhưng quên nhánh cũ, bug chỉ lộ nếu ai đó tắt flag (ví dụ khi debug vấn đề khác). Tệ hơn, nhánh cũ dần rotten vì không ai test: dependency update, schema migration đều chỉ verify nhánh mới.
Bài học: retire flag là một phần của definition of done của feature. Feature chưa retire flag = feature chưa xong. Nên thêm “retire flag” vào acceptance criteria của mọi story có flag, PR cuối cùng của feature là PR xoá flag và nhánh code cũ.
Experiment flag không version metric
Hai tuần A/B test. Dashboard không gắn experiment_version. Tuần thứ ba, designer sửa copy nút CTA, cohort vẫn sticky nhưng ý nghĩa thống kê đã thay đổi. PM kết luận “variant B thắng” nhưng thực tế một phần traffic B đã nhìn thấy copy mới, phần còn lại (cache cũ) vẫn thấy copy gốc. Kết luận dựa trên data không đồng nhất.
Bài học: mọi thay đổi trong experiment, dù chỉ là copy text, phải tạo version mới. Telemetry event phải chứa experiment_id, variant, và version. Dùng số này để segment data khi phân tích, không gộp chung.
Kill-switch bật nhưng edge cache vẫn serve code cũ
CDN cache HTML chứa JS bundle reference cũ. Service worker precache giữ JS cũ. Ops bật DISABLE_HEAVY_REPORT trên server, endpoint trả 503. Nhưng 5 % user vẫn gọi endpoint vì client JS cũ vẫn chạy trong browser, chưa nhận bundle mới. Server chặn request nhưng mỗi request vẫn tốn resource ở gateway level (TLS handshake, routing, trả 503).
Bài học: kill-switch ở application layer không đủ nếu client layer không được invalidate. Cần thêm lớp bảo vệ ở gateway, rate limit endpoint đã disable, hoặc 429 + Retry-After header để client biết dừng retry. Và quan trọng hơn: định nghĩa SLO propagation cho kill-switch bao gồm cả client-side cache invalidation.
Naming convention và flag coupling
Đặt tên flag nghe như chi tiết nhỏ nhưng ảnh hưởng lớn đến khả năng quản lý khi số flag tăng. Convention hoạt động tốt: prefix theo domain (billing*, checkout*, ops_), tên mô tả hành vi không phải tính năng (checkout_async_payment thay vì new_checkout), và hậu tố thời gian nếu cần (checkout_redesign_2024q2).
Tránh tuyệt đối tên mơ hồ: new_flow, temp_fix, test_mode. Sau 3 tháng không ai nhớ “new” là gì, “temp” đã permanent hoá, và “test” đang chạy trên production.
Flag coupling, trạng thái cấm mà runtime vẫn vào được
Coupling xảy ra khi hai flag A và B chỉ hợp lệ trong một số tổ hợp, ví dụ A=ON, B=OFF là hợp lệ nhưng A=ON, B=ON sẽ gây lỗi vì cả hai modify cùng flow checkout theo cách xung đột. Nếu không enforce bằng code hoặc policy, bạn đã tạo trạng thái cấm mà runtime có thể vào bất cứ lúc nào khi ai đó toggle sai thứ tự.
Giải pháp: thay vì hai flag boolean độc lập, dùng enum phase hoặc state machine rõ ràng. Thay vì flag_async_checkout=true và flag_new_payment_ui=true, dùng một flag checkout_phase=legacy|async_backend|full_redesign. Mỗi giá trị enum là một trạng thái hợp lệ đã được test, không có tổ hợp cấm.
Ví dụ: refactor 4 flag boolean thành một enum billing_mode với 3 giá trị. Số trạng thái giảm từ 16 (2^4) xuống 3, test coverage tăng vì test 3 trạng thái dễ hơn test 16 tổ hợp. Code cũng sạch hơn: thay vì nested if/else, dùng switch/match trên enum.
Đánh giá flag infrastructure
Khi chọn cách triển khai flag, self-host (file config, database), managed service (LaunchDarkly, Unleash, Flagsmith), hay sidecar/edge, nên chấm điểm theo bốn tiêu chí.
Availability: khi flag provider chậm hoặc timeout, app hành xử thế nào? Nếu SDK block request chờ evaluate, mỗi request chậm thêm latency của provider. SDK tốt phải có cache local và fallback value, app vẫn chạy với giá trị mặc định khi provider không khả dụng. Fail-open (dùng giá trị mặc định, feature vẫn hoạt động) hay fail-closed (tắt feature khi không evaluate được) là quyết định sản phẩm cần chốt trước khi viết code.
Latency budget: evaluate flag mỗi request hay batch evaluate rồi cache? Evaluate mỗi request thì real-time nhưng tốn latency. Cache evaluate thì nhanh nhưng có propagation delay. Với kill-switch, real-time quan trọng hơn. Với release flag, cache 30 giây là chấp nhận được.
Multi-region: flag có nhất quán giữa region không? Nếu dùng provider global, delay propagation giữa region bao lâu? User ở US thấy feature ON trong khi user ở Asia vẫn OFF có thể gây confuse nếu không design cho trường hợp đó.
Disaster recovery: khi mất kết nối tới provider, có chế độ degrade không? SDK cần cache config local trên disk (không chỉ in-memory) để app restart vẫn có config cuối cùng đã biết, thay vì trả mặc định cho mọi flag.
FAQ cho tech lead
Những câu hỏi hay gặp trong design review hoặc khi team bắt đầu adopt flag:
Có nên cho PM tự bật flag production? Tuỳ loại. Experiment flag thường cần PM control, họ biết khi nào sample size đủ, khi nào dừng test. Kill-switch thì nên giới hạn role kỹ thuật + quy trình break-glass (hai người xác nhận cho ops flag ảnh hưởng > 50 % traffic). Quan trọng là audit log, không phải cấm PM mà là ghi lại ai làm gì, khi nào, vì sao.
Bao nhiêu flag là quá nhiều? Không có con số phổ quát vì phụ thuộc vào complexity của hệ thống. Thay vì đếm số tuyệt đối, có thể dùng entropy budget: nếu một engineer mới join team không thể vẽ sơ đồ “các flag ảnh hưởng checkout flow” trong 30 phút, bạn đã vượt ngưỡng vận hành. Lúc đó cần retire flag cũ hoặc gom flag coupling thành enum.
Flag có thay thế được kiến trúc module? Không. Flag điều khiển lộ trình triển khai và thử nghiệm. Ranh giới module, contract giữa service, interface design, vẫn cần thiết kế riêng. Flag mà dùng để “chọn implementation” lâu dài thì bạn đang dùng flag như dependency injection framework, sai tool cho bài toán đó.
Tóm tắt
Feature flag trên production không phải if/else, nó là hệ thống phân phối hành vi có blast radius, có cache, có propagation delay, và có nợ kỹ thuật nếu không retire. Phân loại flag theo taxonomy (release, experiment, ops) giúp gán đúng lifecycle, owner, và mức kiểm soát. Retire release flag là nợ kỹ thuật nếu quên, thêm vào definition of done, đặt quota mỗi sprint.
Kill-switch cần SLO propagation riêng và lớp bảo vệ gateway, không phụ thuộc hoàn toàn vào flag propagation qua CDN hay client cache. Flag coupling giải quyết bằng enum phase thay vì boolean độc lập, giảm trạng thái cần test, giảm tổ hợp cấm.
Governance không phải “mua tool đắt”, nó là naming convention, audit log, RBAC theo environment, và quy tắc retire. Mức governance tỷ lệ thuận với quy mô team: startup 8 người cần sheet Google Sheets, tổ chức 50+ team cần platform evaluate chuẩn hoá. Bắt đầu đơn giản, thêm khi thấy đau, không thiết kế governance cho scale chưa có.