用 Feature Flag 做架构演进的安全阀
一次重构翻车之后的反思
去年我负责把一个单体服务的用户模块拆成独立微服务。代码审查通过、本地测试全绿、预发环境跑了两周没出问题。上线当天,切流量的瞬间,老服务的缓存和新服务的数据库之间出现了不一致——部分用户的登录态丢失了。
回滚花了 40 分钟。事后复盘,问题的根源不是代码逻辑错了,而是切换太「干脆」:没有灰度、没有 shadow 比对、没有退路。
后来我们在类似场景引入了 Feature Flag,把架构改造从「一刀切」变成「逐步放量」。这篇文章把我踩过的坑和总结出来的方法论写下来。
Feature Flag 在架构演进中的定位
Feature Flag 在多数团队里的第一用途是产品灰度——给 5% 用户开新功能、做 A/B 测试。但在架构演进场景下,它的角色不同:
| 维度 | 产品灰度 Flag | 架构演进 Flag |
|---|---|---|
| 控制对象 | 用户可见的功能 | 内部调用链路、存储路径 |
| 切换粒度 | 按用户百分比 | 按租户、地域、流量比例 |
| 回滚要求 | 关 Flag 即可 | 要确认数据一致性 |
| 生命周期 | 实验结束即删 | 迁移完成后再观察一段时间 |
| 风险等级 | 功能不可用 | 数据丢失或不一致 |
Martin Fowler 在 2017 年的一篇 Feature Toggles 文章 中把 Toggle 分成四类:Release Toggles、Experiment Toggles、Ops Toggles 和 Architecture Toggles。架构演进用的是第四类——Architecture Toggles,用来控制新旧代码路径的切换,本质上是在生产环境做「路由决策」。
Pete Hodgson(那篇文章的合著者)强调:Architecture Toggles 的关键特征是「新旧路径必须能同时存在」,而且「切换的粒度要细到可以按请求级别控制」。
案例一:数据库迁移——从 MySQL 到 TiDB
场景
订单表从 MySQL 迁移到 TiDB,数据量 2 亿行。业务要求零停机,回滚时间不超过 1 分钟。
翻车预演
如果直接切:新库写入失败(字段编码不一致)、读取延迟高(索引没预热)、回滚时发现新库已经写入了新数据,老库缺失最近 30 分钟的订单。
方案:六阶段迁移 Flag
参考 LaunchDarkly 的 Migration Flags 文档,我设计了六个阶段:
每个阶段的行为:
| 阶段 | 写入 | 读取 | Flag 值 |
|---|---|---|---|
| off | 只写 MySQL | 只读 MySQL | false |
| dualwrite | MySQL + TiDB 双写 | 只读 MySQL | "dualwrite" |
| shadow | MySQL + TiDB 双写 | 读 MySQL,TiDB 结果只比对不返回 | "shadow" |
| live | 只写 TiDB | 读 TiDB | "live" |
| rampdown | 只写 TiDB | 读 TiDB,MySQL 保留只读副本做兜底 | "rampdown" |
| complete | 只写 TiDB | 只读 TiDB | true |
代码示例
❌ 坏做法:硬编码切换,没有回退路径
// 直接切换数据源,出问题只能回滚代码
async function getOrder(orderId: string) {
// 改了一行配置就切了,没有任何缓冲
return await tiDbClient.query('SELECT * FROM orders WHERE id = ?', [orderId])
}✅ 好做法:Flag 驱动的多阶段路由
type MigrationStage = 'off' | 'dualwrite' | 'shadow' | 'live' | 'rampdown' | 'complete'
async function getOrder(orderId: string) {
const stage = await getFlag('order-db-migration', 'off') as MigrationStage
switch (stage) {
case 'off':
return readFromMySQL(orderId)
case 'dualwrite':
// 双写阶段还在读老库
return readFromMySQL(orderId)
case 'shadow': {
// 新老库同时读,比对结果但只返回老库数据
const [oldResult, newResult] = await Promise.all([
readFromMySQL(orderId),
readFromTiDB(orderId),
])
compareAndAlert(oldResult, newResult, orderId) // 不一致时告警
return oldResult
}
case 'live':
case 'rampdown':
return readFromTiDB(orderId)
case 'complete':
return readFromTiDB(orderId)
default:
return readFromMySQL(orderId) // 兜底走老路
}
}写操作的双写逻辑更复杂,需要处理一致性:
async function createOrder(order: OrderInput) {
const stage = await getFlag('order-db-migration', 'off') as MigrationStage
if (stage === 'off') {
return writeToMySQL(order)
}
if (stage === 'dualwrite' || stage === 'shadow') {
// 先写老库成功,再异步写新库
const result = await writeToMySQL(order)
// 新库写入失败不阻塞主流程,但要告警
writeToTiDB(order).catch(err => {
logger.error('TiDB dual-write failed', { orderId: order.id, error: err })
metrics.increment('migration.dualwrite.failure')
})
return result
}
// live 之后只写新库
return writeToTiDB(order)
}关键经验
- dualwrite 阶段至少跑 3 天。我们在第二天发现了一个字符集问题——TiDB 的
utf8mb4排序规则和 MySQL 有细微差异,导致按时间排序的分页接口偶尔出现重复数据。 - shadow 阶段的比对要容忍合理差异。比如
created_at的精度(MySQL 是秒级,TiDB 是微秒级),比对时得做截断,否则告警会淹没值班群。 - rampdown 阶段保留老库只读副本一周。有 2 个边缘接口漏切了,靠这个兜底撑了过去。
案例二:API 网关迁移——从 Nginx 到 Envoy
场景
公司的 API 网关从 Nginx + Lua 迁移到 Envoy + 自研控制面。涉及 200+ 路由规则、30 个上游服务。
问题
第一次尝试是全量切换——改 Nginx 配置,把流量全部打到 Envoy。上线后发现 Envoy 的 connection pool 配置不合理,导致某几个上游服务的 P99 延迟从 50ms 飙到 800ms。Nginx 那边已经停了 Lua 脚本,回不去。
修复方案:按路由粒度灰度
用 Flag 控制每个路由的流量分配,而不是一次切全部:
// 路由级别灰度配置
interface RouteGrayConfig {
enabled: boolean // 是否走 Envoy
weight: number // 流量百分比 0-100
fallbackToNginx: boolean // Envoy 出错是否回退
}
// Flag payload 示例
{
"user-service": { "enabled": true, "weight": 10, "fallbackToNginx": true },
"order-service": { "enabled": false, "weight": 0, "fallbackToNginx": true },
"payment-service": { "enabled": true, "weight": 100, "fallbackToNginx": false }
}❌ 坏做法:全量切换,没有路由级别控制
# 一刀切,所有流量都走 Envoy
upstream backend {
server envoy-gateway:10000;
# nginx 配置全删了,回不去
}✅ 好做法:路由级别灰度 + 自动回退
async function routeRequest(req: IncomingRequest) {
const routeName = extractRouteName(req)
const config = await getFlag('gateway-migration-routes', {}) as Record<string, RouteGrayConfig>
const routeConfig = config[routeName] ?? { enabled: false, weight: 0, fallbackToNginx: true }
// 未开启或权重为 0,走老网关
if (!routeConfig.enabled || routeConfig.weight === 0) {
return forwardToNginx(req)
}
// 按权重决定是否走新网关
if (Math.random() * 100 > routeConfig.weight) {
return forwardToNginx(req)
}
try {
const response = await forwardToEnvoy(req, { timeout: 3000 })
// 监控响应质量,异常时自动降级
if (response.status >= 500 || response.latency > 5000) {
metrics.increment('gateway.envoy.degraded', { route: routeName })
if (routeConfig.fallbackToNginx) {
return forwardToNginx(req) // 自动回退
}
}
return response
} catch (err) {
if (routeConfig.fallbackToNginx) {
metrics.increment('gateway.envoy.fallback', { route: routeName })
return forwardToNginx(req)
}
throw err
}
}灰度节奏:
| 周次 | 路由数 | 权重 | 关注指标 |
|---|---|---|---|
| 第 1 周 | 5 个内部路由 | 10% | 错误率、P99 延迟 |
| 第 2 周 | 20 个低风险路由 | 50% | 上游超时、连接池使用率 |
| 第 3 周 | 全部路由 | 10%→50% | 全链路延迟分布 |
| 第 4 周 | 全部路由 | 100% | 去掉 fallback,观察稳定性 |
关键经验
- 先切内部路由。用户无感知,团队可以熟悉 Envoy 的行为模式。
- fallback 是必须的。第 2 周的时候有一个路由的 gRPC 转码配置写错了,fallback 救了命。
- 权重调整不要太激进。从 10% 到 50% 我们观察了整整 3 天。
案例三:消息队列迁移——从 RabbitMQ 到 Kafka
场景
事件驱动架构升级,消息队列从 RabbitMQ 迁移到 Kafka。难点在于消费者端的处理逻辑不同(RabbitMQ 是 push,Kafka 是 pull),而且消息的幂等性保证方式不一样。
问题
直接切的时候,部分消费者同时从两个队列消费了同一条消息,导致重复处理。另外,Kafka 的消息顺序保证和 RabbitMQ 不同,依赖顺序的业务逻辑出了问题。
方案:Dark Launch + 双消费 + 比对
// Flag 控制消费策略
type ConsumeStrategy = 'old-only' | 'dual-consume' | 'new-only'
async function processMessage(rawMessage: RawMessage) {
const strategy = await getFlag('mq-migration-strategy', 'old-only') as ConsumeStrategy
if (strategy === 'old-only') {
return processViaRabbitMQ(rawMessage)
}
if (strategy === 'dual-consume') {
// 两条队列都消费,但只以老队列结果为准
const [oldResult, newResult] = await Promise.allSettled([
processViaRabbitMQ(rawMessage),
processViaKafka(rawMessage),
])
// 比对两个结果,不一致时记录告警
if (oldResult.status === 'fulfilled' && newResult.status === 'fulfilled') {
if (!shallowEqual(oldResult.value, newResult.value)) {
logger.warn('MQ migration result mismatch', {
messageId: rawMessage.id,
oldResult: oldResult.value,
newResult: newResult.value,
})
metrics.increment('mq.migration.mismatch')
}
}
// 始终返回老队列的处理结果
if (oldResult.status === 'fulfilled') return oldResult.value
throw oldResult.reason
}
// new-only 阶段
return processViaKafka(rawMessage)
}❌ 坏做法:两个消费者同时处理,没有去重
// RabbitMQ 消费者
rabbitChannel.consume('orders', async (msg) => {
await handleOrder(msg.content)
})
// Kafka 消费者(同时启动,处理相同消息)
await kafkaConsumer.run({
eachMessage: async ({ message }) => {
await handleOrder(message.value) // 同一条消息被处理两次
},
})✅ 好做法:消费者端幂等 + Flag 控制切换
async function handleOrder(messageContent: Buffer) {
const parsed = JSON.parse(messageContent.toString())
const { orderId, idempotencyKey } = parsed
// 幂等检查:无论哪个队列来的消息,同一个 key 只处理一次
const existing = await redis.get(`order:processed:${idempotencyKey}`)
if (existing) {
logger.info('Duplicate message skipped', { orderId, idempotencyKey })
return
}
// 业务处理
const result = await processOrder(parsed)
// 记录已处理,TTL 和幂等窗口对齐(比如 24 小时)
await redis.set(`order:processed:${idempotencyKey}`, '1', 'EX', 86400)
return result
}关键经验
- 幂等性是前提。没有幂等保证的消费者,Flag 切得再细也会出问题。
- dual-consume 阶段的比对不是实时的。我们跑了 5 天,每天跑一次离线脚本比对两个队列的处理结果。
- 消息顺序问题要单独处理。我们在 Kafka 端按
orderId做 partition key,保证同一订单的消息有序。
Flag 类型与架构场景的匹配
不是所有 Flag 都适合做架构演进。不同类型的 Flag 有不同的适用场景:
| Flag 类型 | 适用场景 | 生命周期 | 典型配置 |
|---|---|---|---|
| Release Toggle | 新功能灰度发布 | 天~周 | 按用户百分比 |
| Experiment Toggle | A/B 测试 | 周~月 | 按实验分组 |
| Ops Toggle | 运维开关(降级、限流) | 永久 | 按运维策略 |
| Architecture Toggle | 架构迁移路径切换 | 周~月 | 按阶段、租户、地域 |
架构演进用的是 Architecture Toggle,它和 Release Toggle 的区别在于:
- 关注点不同。Release Toggle 关心「用户能不能看到这个功能」,Architecture Toggle 关心「请求走哪条处理路径」。
- 回滚复杂度不同。关 Release Toggle 只是隐藏功能,关 Architecture Toggle 可能要处理数据不一致。
- 清理时机不同。Release Toggle 在全量后就能删,Architecture Toggle 要等新路径稳定运行一段时间后再删。
还有一个容易被忽略的点:Architecture Toggle 的评估位置。Flag 在哪里计算,直接影响性能和可用性。
| 评估方式 | 适用场景 | 延迟影响 | 可用性风险 |
|---|---|---|---|
| 客户端评估 | 前端 UI 灰度 | 无额外网络请求 | Flag 服务不可用时用本地缓存 |
| 服务端评估 | 后端链路切换 | 增加一次 Flag 查询 | 需要 SDK 本地缓存兜底 |
| 边缘评估 | 网关层路由决策 | 最低,靠近用户 | 边缘节点需要独立拉取配置 |
架构迁移场景通常用服务端评估。原因是:路径切换的判断往往依赖服务端上下文(租户 ID、请求来源、数据库状态),客户端拿不到这些信息。但要注意,每次请求都去远端查 Flag 会增加延迟。我通常的做法是:SDK 启动时拉取全量配置到本地内存,后续用本地缓存评估,后台定时轮询更新。这样即使 Flag 服务挂了,应用还能用最后一次缓存的配置继续运行。
回滚路径设计
有 Flag 不等于安全。切换之前要把回滚路径想清楚:
回滚前要回答的问题:
- 关闭 Flag 后,请求能回到旧路径吗?
- 新路径已经写入的数据,旧路径能读吗?
- 新路径产生的副作用(发了邮件、推了消息、改了状态),能撤回吗?
- 缓存里已经有了新路径的数据,需要清理吗?
- 旧路径的代码和配置还在吗?
回滚的两种类型:
第一种是「无损回滚」。Flag 切回去之后,请求直接走回旧路径,不需要任何数据修补。适用于只读迁移或者读路径切换,比如搜索引擎从 Elasticsearch 切到 Meilisearch,Flag 切回去只是查询走老引擎,数据没有变化。
第二种是「有损回滚」。Flag 切回去之后,还需要处理新路径已经产生的数据。比如数据库双写阶段,新库已经有了数据,回滚时要么把新库的数据同步回老库,要么丢弃新库的数据。丢弃意味着丢订单,同步意味着延迟恢复。选择哪种取决于业务对数据丢失的容忍度。
❌ 坏做法:Flag 只管切,不管回滚
async function migrateUserData(userId: string) {
const useNewService = await getFlag('user-migration', false)
if (useNewService) {
// 新服务写入后,老服务的数据就过期了
const result = await newUserService.migrate(userId)
// 如果这里出问题,老服务的数据已经被标记为「已迁移」
await oldUserService.markMigrated(userId)
return result
}
return oldUserService.getData(userId)
}✅ 好做法:写入时保留回滚能力
async function migrateUserData(userId: string) {
const useNewService = await getFlag('user-migration', false)
if (useNewService) {
try {
// 新服务写入
const result = await newUserService.migrate(userId)
// 不立即标记旧数据为「已迁移」,而是记录一个中间状态
await oldUserService.markMigrating(userId) // 注意:是 migrating,不是 migrated
return result
} catch (err) {
// 新服务失败,旧数据不受影响
logger.error('New user service migration failed', { userId, error: err })
// 降级到旧服务
return oldUserService.getData(userId)
}
}
return oldUserService.getData(userId)
}
// 回滚时的清理逻辑
async function rollbackUserMigration(userId: string) {
// 中间状态的数据可以安全回滚
const status = await oldUserService.getMigrationStatus(userId)
if (status === 'migrating') {
await oldUserService.resetMigrationStatus(userId)
await newUserService.rollback(userId)
}
}Flag 治理:别让安全阀变成技术债
Unleash 的 最佳实践文档 提到一个数据:成熟的工程团队通常有数百个活跃的 Flag,其中 30% 以上已经不需要了。GrowthBook 的 技术债指南 也指出,没有及时清理的 Flag 是代码复杂度的主要来源之一。
Flag sprawl 的代价:
- 代码分支膨胀,每个 Flag 增加一个
if分支,五个 Flag 叠加就是 32 条路径 - 测试矩阵指数级增长,QA 不可能覆盖所有组合
- 新人读代码时不知道哪些 Flag 还活着,不敢删也不敢改
- 某个 Flag 意外开启导致线上问题,而没有人知道它是干什么的
- CI 流水线变慢,因为要跑的测试用例越来越多
我在一个项目里见过 147 个活跃 Flag,其中超过 60 个已经没有人能说清楚用途。最后花了两个迭代做专项清理,删掉了 40 多个,剩下的 20 多个补上了负责人和过期时间。
❌ 坏做法:Flag 永远不清理
// 三年前的「临时」Flag,到现在还在
async function getDashboard(userId: string) {
const v2 = await getFlag('dashboard-v2', false)
const newLayout = await getFlag('dashboard-new-layout-2023', false)
const experiment = await getFlag('dashboard-experiment-q3', false)
const perf = await getFlag('dashboard-perf-improvement', false)
// 四层嵌套,没人知道哪些还能删
if (v2) {
if (newLayout) {
if (experiment) { /* ... */ }
}
}
}✅ 好做法:Flag 生命周期管理
// 每个 Flag 有明确的元信息
interface FlagMetadata {
owner: string // 负责人
createdAt: string // 创建时间
expiresAt: string // 过期时间
purpose: string // 用途说明
cleanupTicket: string // 清理工单链接
}
// 创建 Flag 时就必须填写
const flagMeta: FlagMetadata = {
owner: 'team-order',
createdAt: '2026-06-01',
expiresAt: '2026-08-01', // 两个月后必须清理
purpose: '订单库 MySQL → TiDB 迁移',
cleanupTicket: 'JIRA-1234',
}清理的检查清单:
| 检查项 | 动作 |
|---|---|
| Flag 已全量(100%)超过 30 天 | 可以删除 Flag,保留新代码路径 |
| Flag 已全关超过 30 天 | 可以删除 Flag 和对应的旧代码路径 |
| Flag 没有负责人 | 指派负责人或标记为可删除 |
| Flag 超过过期时间 | 自动告警,纳入清理计划 |
| Flag 关联的代码分支已不可达 | 直接删除死代码 |
上线前检查清单
创建阶段
- Flag 命名包含项目/模块前缀,如
order-db-migration,不要用temp-flag或test-123 - 指定唯一负责人(owner),写进 Flag 元信息
- 设置过期时间,原则上不超过 3 个月
- 写清楚用途说明,让其他人能看懂这个 Flag 是干什么的
- 确定 Flag 的默认值(通常是
false或旧路径)
灰度阶段
- 定义明确的阶段和每个阶段的准入/准出标准
- 监控指标已经就位(错误率、延迟、数据一致性比对)
- 告警规则已经配置(不一致率超过阈值自动通知)
- 回滚路径已经验证(在预发环境实际跑过一次回滚)
- 数据兼容性已经确认(新路径写入的数据,旧路径能读)
清理阶段
- Flag 全量或全关已经超过观察期(建议 30 天)
- 旧代码路径的删除 PR 已经提交
- 相关文档已更新(架构图、运维手册)
- Flag 配置已从远端删除或归档
- 测试用例中不再引用已删除的 Flag
日常治理
- 每个迭代检查一次过期 Flag 清单
- 每季度 Flag 清理数量不低于新增数量的 80%
- 代码审查时关注新增 Flag 是否符合规范
- 定期 Flag 使用报告(活跃数、过期数、无主数)
写在最后
Feature Flag 在架构演进里的价值,不是「让切换变快」,而是「让切换变安全」。它把一个大决策拆成很多小决策,每一步都可以停下来看看效果、退回去换个方向。
三个案例做下来,我有一个体会:架构演进的难度往往不在技术本身,而在「怎么让团队有信心推进」。Flag 给出的不只是回滚能力,还有一种「可控感」——知道出了问题能在分钟级别收场,工程师才敢放手去做。
但 Flag 本身也有成本。每多一个 Flag,代码就多一条分支,测试就多一个维度,运维就多一个配置。所以用完一定要清理。Flag 是脚手架,不是建筑物的一部分。楼盖好了,脚手架要拆掉。
我现在的习惯是:创建 Flag 的时候就想好怎么删它,就像写代码的时候就想好怎么测它。这个习惯帮我避免了很多「临时方案变永久架构」的问题。
参考资料
- Feature Toggles — Martin Fowler
- Performing Multi-stage Migrations with Migration Flags — LaunchDarkly
- 11 Best Practices for Building and Scaling Feature Flag Systems — Unleash
- How Engineering Teams Reduce Feature Flag Technical Debt — GrowthBook
- Architecture, Best Practices, and the Path to Progressive Delivery — Zylos AI
- Reducing Database Migration Risk with Feature Flags — CloudBees
- Feature Flags Best Practices: The Complete Guide — Flagsmith