Feature Flags với Unleash & Spring Boot
Tung Bui Thanh (TungDaDev) (updated 3 weeks ago)10 mins read

Ở nhiều team, ngày deploy (Deployment Day) vẫn là ngày căng như dây đàn: feature branch sống cả tháng trời rồi mới merge vào nhánh chính, conflict chồng conflict (Merge Hell), release lúc nửa đêm ai cũng thót tim, và chỉ một lỗi nhỏ là cả team cuống cuồng rollback code, mà rollback cũng đâu có an toàn.
Các team làm continuous delivery thoát khỏi chuyện này nhờ tách bạch hai khái niệm:
Deployment (Hành động Kỹ thuật) != Release (Hành động Nghiệp vụ)- Deploy: Đưa code mới lên production liên tục, ngày nào cũng làm (Trunk-Based Development). Code có thể chứa tính năng chưa xong, nhưng tính năng đó bị "khoá" lại bằng công tắc.
- Release: Bật tính năng cho nhóm người dùng mục tiêu (1% -> 10% -> 100%) khi hệ thống đã sẵn sàng và các chỉ số SRE đều đẹp.
Feature Flags là thứ làm được việc này. Với Java và Spring Boot, Unleash là một trong những nền tảng mã nguồn mở phổ biến nhất: tự host được (self-hosted), dữ liệu không ra khỏi nhà mình, và đánh giá flag ngay trong bộ nhớ của app, không tốn một lượt gọi mạng nào.
Phần dưới đi qua cách phân loại flag, cách Unleash chạy bên trong, rồi tới mấy pattern giúp codebase không bị chìm trong if-else.
# 4 nhóm feature toggle
Không phải flag nào cũng giống nhau. Trong bài viết nổi tiếng trên trang của Martin Fowler, Pete Hodgson chia Feature Flags thành 4 nhóm, mỗi nhóm có vòng đời và đặc điểm kỹ thuật khác hẳn nhau:
| Loại toggle | Thường sống bao lâu | Hay thay đổi không | Ai cầm công tắc? |
|---|---|---|---|
| Release Toggle | 1 - 2 tuần | Ít (bật dần theo đợt) | Team phát triển (Dev / QA) |
| Experiment Toggle | 2 - 4 tuần | Theo từng request (chia nhóm user) | Product Manager / Data Analyst |
| Ops Toggle (Kill Switch) | Vài tuần; kill switch thì vĩnh viễn | Bất cứ lúc nào (khẩn cấp) | Team vận hành SRE / DevOps |
| Permission Toggle | Vĩnh viễn | Thay đổi theo từng request | Hệ thống thanh toán / RBAC |
Unleash cũng có sẵn các "flag type" gần khớp với cách chia này: release, experiment, operational, kill-switch, permission. Chọn đúng type lúc tạo flag thì Unleash biết khi nào nên đánh dấu flag là "potentially stale" (ví dụ release và experiment là sau 40 ngày, operational sau 7 ngày; kill switch và permission thì không bao giờ), rất tiện cho việc dọn flag ở phần cuối bài.
# unleash đánh giá flag ngay trong bộ nhớ phía client
Mới dùng Feature Flags, nhiều bạn hay lo: mỗi lần gọi unleash.isEnabled("my-flag") lại phải bắn một HTTP request tới Unleash Server, vậy API có chậm đi không?
Không đâu. Unleash dùng kiểu đánh giá ngay trong bộ nhớ phía client (Client-Side Evaluation):
# chịu lỗi tốt (fault-tolerant)
- Gần như không tốn độ trễ lúc runtime: Flag được đánh giá ngay trong process của app, không có lượt gọi mạng nào, nên gọi
isEnabled()trong mỗi request là chuyện bình thường. Từ SDK v10, phần đánh giá rule nằm trong engine Yggdrasil (viết bằng Rust, nhúng vào SDK), dùng chung logic với các SDK ngôn ngữ khác để mọi nơi cho ra cùng một kết quả. - File backup local: Nếu app khởi động lại đúng lúc Unleash Server gặp sự cố mạng, Unleash Java SDK sẽ tự đọc file snapshot backup trên disk, app vẫn khởi động bình thường với bộ flag gần nhất, không crash.
Mặc định SDK hỏi server mỗi 15 giây (polling), nên đổi flag trên UI thì app thấy sau tối đa một chu kỳ. Từ Java SDK 11.1 có thêm chế độ streaming qua Server-Sent Events: server đẩy thay đổi xuống ngay, mất kết nối thì SDK tự quay về polling. Chế độ này cần Unleash Enterprise (qua Unleash Edge), bản open source thì vẫn là polling.
TIP
Bật synchronousFetchOnInitialisation(true) để bean Unleash chặn lúc khởi tạo cho tới khi kéo xong bộ flag lần đầu, tránh vài giây đầu mọi flag đều trả false. Lưu ý là nếu lúc đó server không trả lời được, SDK sẽ lặng lẽ bỏ qua và chạy tiếp (dùng file backup nếu có), chứ không làm app fail. Vì vậy nên truyền giá trị mặc định an toàn khi gọi, ví dụ isEnabled("flag", false).
# murmurHash3 trong gradual rollout
Làm sao Unleash bật tính năng cho đúng 20% người dùng, chọn ngẫu nhiên, mà vẫn "dính" (sticky consistency): người đã thấy tính năng mới thì những lần sau chắc chắn vẫn thấy?
Unleash dùng hàm băm không dành cho mã hoá MurmurHash3:
Rollout Score = (MurmurHash3(FeatureName + ":" + UserId) % 100) + 1# vì sao cách này hay
- Tính tại chỗ: Server Spring Boot tự tính ra
Score, không cần lưu trạng thái người dùng vào database hay Redis. Phần trước dấu:thực ra làgroupIdcủa strategy, mặc định bằng tên flag. Vì có tên flag trong đó nên mỗi flag chia người dùng theo một cách khác nhau: một user rơi vào 10% đầu của flag A chưa chắc đã rơi vào 10% đầu của flag B. Muốn nhiều flag bật cho cùng một nhóm user thì đặt chunggroupId. - Luôn ra cùng kết quả (determinism): Cùng
UserId, cùng tên flag thì luôn ra đúng một con số. Tăng rollout từ 10% lên 20% thì 10% người dùng cũ vẫn giữ nguyên, chỉ có thêm 10% người mới được bật.
# dẹp if-else hell bằng strategy pattern & spring di
Một tác dụng phụ khó chịu của Feature Flags là code dễ biến thành một mớ rẽ nhánh lồng nhau:
// MÃ NGUỒN KÉM CHẤT LƯỢNG (Code Smell):
public void processPayment(PaymentRequest req) {
if (unleash.isEnabled("v2-payment-engine")) {
if (unleash.isEnabled("use-stripe-gateway")) {
// ...
} else {
// ...
}
} else {
// ...
}
}# gom về một strategy router theo flag
Thay vì rải if-else khắp các tầng nghiệp vụ, mình gói mỗi phiên bản tính năng thành một Strategy riêng và để một Delegating Router chọn cái nào chạy:
# làm với spring boot
# bước 1: interface chung cho nghiệp vụ
public interface PaymentProcessor {
PaymentReceipt execute(PaymentRequest request);
}# bước 2: hai implementation tách riêng
@Component("legacyV1PaymentProcessor")
public class LegacyV1PaymentProcessor implements PaymentProcessor {
@Override
public PaymentReceipt execute(PaymentRequest request) {
// Logic thanh toán cũ ổn định
return processLegacy(request);
}
}
@Component("modernV2PaymentProcessor")
public class ModernV2PaymentProcessor implements PaymentProcessor {
@Override
public PaymentReceipt execute(PaymentRequest request) {
// Logic thanh toán mới tối ưu hóa
return processModern(request);
}
}# bước 3: delegating router gọn gàng
@Service
@Primary // Đánh dấu bean này là đại diện mặc định để inject vào Controller
public class RoutingPaymentProcessor implements PaymentProcessor {
private final Unleash unleash;
private final PaymentProcessor legacyProcessor;
private final PaymentProcessor modernProcessor;
public RoutingPaymentProcessor(
Unleash unleash,
@Qualifier("legacyV1PaymentProcessor") PaymentProcessor legacyProcessor,
@Qualifier("modernV2PaymentProcessor") PaymentProcessor modernProcessor) {
this.unleash = unleash;
this.legacyProcessor = legacyProcessor;
this.modernProcessor = modernProcessor;
}
@Override
public PaymentReceipt execute(PaymentRequest request) {
UnleashContext context = UnleashContext.builder()
.userId(request.customerId())
.build();
// Điểm rẽ nhánh duy nhất trong toàn bộ hệ thống!
if (unleash.isEnabled("enable-modern-v2-payment", context)) {
return modernProcessor.execute(request);
}
return legacyProcessor.execute(request);
}
}Khi tính năng mới đã chạy ổn 100% được 2 tuần, bạn chỉ cần xoá LegacyV1PaymentProcessor và RoutingPaymentProcessor, để ModernV2PaymentProcessor thành bean duy nhất. Code phía Controller không phải sửa gì, vì nó chỉ biết tới interface PaymentProcessor.
# cấu hình production & dọn nợ kỹ thuật
# cấu hình unleash client cho production
@Configuration
public class UnleashConfigProvider {
@Bean
public Unleash unleash(
@Value("${unleash.api-url}") String apiUrl,
@Value("${unleash.api-token}") String apiToken,
@Value("${unleash.instance-id}") String instanceId) {
UnleashConfig config = UnleashConfig.builder()
.appName("core-banking-backend")
.instanceId(instanceId)
.unleashAPI(apiUrl)
.apiKey(apiToken)
.synchronousFetchOnInitialisation(true) // Chặn khởi động cho tới khi kéo xong config lần đầu
.fetchTogglesInterval(15) // Đồng bộ ngầm sau mỗi 15 giây
.sendMetricsInterval(60) // Gửi báo cáo sử dụng flag lên server sau mỗi 60 giây
.backupFile(System.getProperty("java.io.tmpdir") + "/unleash-repo.json") // File dự phòng
.build();
return new DefaultUnleash(config);
}
}# quản lý vòng đời của flag
- Ngày tạo flag: Tạo luôn 1 ticket triển khai và 1 ticket xoá flag (tech debt removal) trên Jira.
- Luật 30 ngày: Release Toggle nào nằm trên production quá 30 ngày là coi như phạm luật kiến trúc.
- Theo dõi bằng metrics: Metrics mặc định của Unleash chỉ đếm số lần flag được đánh giá ra bật/tắt, đủ để biết flag nào không còn ai dùng. Muốn so tỷ lệ chuyển đổi và tỷ lệ lỗi giữa hai nhánh thì gắn tên flag/variant làm tag vào metrics của chính app (Micrometer), hoặc bật impression data của Unleash, rồi vẽ trên Grafana.
# tổng kết
Dùng Feature Flags với Unleash thì thay đổi cả cách team làm việc, chứ không đơn thuần là thêm một mẹo viết code:
- Hết sợ release: Dev merge code vào
mainnhiều lần một ngày cũng thấy yên tâm. - Kiểm soát rủi ro: Tính năng lỗi thì tắt trên UI, app nhận thay đổi sau một chu kỳ polling (vài giây), không cần rollback code hay restart server.
- Trải nghiệm người dùng tốt hơn: Làm được canary release, A/B testing, và gặp sự cố thì phục hồi nhanh.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.