用 Feature Flag 做架构演进的安全阀

0阅读11分钟

一次重构翻车之后的反思

去年我负责把一个单体服务的用户模块拆成独立微服务。代码审查通过、本地测试全绿、预发环境跑了两周没出问题。上线当天,切流量的瞬间,老服务的缓存和新服务的数据库之间出现了不一致——部分用户的登录态丢失了。

回滚花了 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 文档,我设计了六个阶段:

流程图画布 · 115%
Mermaid 流程图加载中...

每个阶段的行为:

阶段写入读取Flag 值
off只写 MySQL只读 MySQLfalse
dualwriteMySQL + TiDB 双写只读 MySQL"dualwrite"
shadowMySQL + TiDB 双写读 MySQL,TiDB 结果只比对不返回"shadow"
live只写 TiDB读 TiDB"live"
rampdown只写 TiDB读 TiDB,MySQL 保留只读副本做兜底"rampdown"
complete只写 TiDB只读 TiDBtrue

代码示例

❌ 坏做法:硬编码切换,没有回退路径

// 直接切换数据源,出问题只能回滚代码
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 ToggleA/B 测试周~月按实验分组
Ops Toggle运维开关(降级、限流)永久按运维策略
Architecture Toggle架构迁移路径切换周~月按阶段、租户、地域

架构演进用的是 Architecture Toggle,它和 Release Toggle 的区别在于:

  1. 关注点不同。Release Toggle 关心「用户能不能看到这个功能」,Architecture Toggle 关心「请求走哪条处理路径」。
  2. 回滚复杂度不同。关 Release Toggle 只是隐藏功能,关 Architecture Toggle 可能要处理数据不一致。
  3. 清理时机不同。Release Toggle 在全量后就能删,Architecture Toggle 要等新路径稳定运行一段时间后再删。

还有一个容易被忽略的点:Architecture Toggle 的评估位置。Flag 在哪里计算,直接影响性能和可用性。

评估方式适用场景延迟影响可用性风险
客户端评估前端 UI 灰度无额外网络请求Flag 服务不可用时用本地缓存
服务端评估后端链路切换增加一次 Flag 查询需要 SDK 本地缓存兜底
边缘评估网关层路由决策最低,靠近用户边缘节点需要独立拉取配置

架构迁移场景通常用服务端评估。原因是:路径切换的判断往往依赖服务端上下文(租户 ID、请求来源、数据库状态),客户端拿不到这些信息。但要注意,每次请求都去远端查 Flag 会增加延迟。我通常的做法是:SDK 启动时拉取全量配置到本地内存,后续用本地缓存评估,后台定时轮询更新。这样即使 Flag 服务挂了,应用还能用最后一次缓存的配置继续运行。

回滚路径设计

有 Flag 不等于安全。切换之前要把回滚路径想清楚:

回滚前要回答的问题:

  1. 关闭 Flag 后,请求能回到旧路径吗?
  2. 新路径已经写入的数据,旧路径能读吗?
  3. 新路径产生的副作用(发了邮件、推了消息、改了状态),能撤回吗?
  4. 缓存里已经有了新路径的数据,需要清理吗?
  5. 旧路径的代码和配置还在吗?

回滚的两种类型:

第一种是「无损回滚」。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-flagtest-123
  • 指定唯一负责人(owner),写进 Flag 元信息
  • 设置过期时间,原则上不超过 3 个月
  • 写清楚用途说明,让其他人能看懂这个 Flag 是干什么的
  • 确定 Flag 的默认值(通常是 false 或旧路径)

灰度阶段

  • 定义明确的阶段和每个阶段的准入/准出标准
  • 监控指标已经就位(错误率、延迟、数据一致性比对)
  • 告警规则已经配置(不一致率超过阈值自动通知)
  • 回滚路径已经验证(在预发环境实际跑过一次回滚)
  • 数据兼容性已经确认(新路径写入的数据,旧路径能读)

清理阶段

  • Flag 全量或全关已经超过观察期(建议 30 天)
  • 旧代码路径的删除 PR 已经提交
  • 相关文档已更新(架构图、运维手册)
  • Flag 配置已从远端删除或归档
  • 测试用例中不再引用已删除的 Flag

日常治理

  • 每个迭代检查一次过期 Flag 清单
  • 每季度 Flag 清理数量不低于新增数量的 80%
  • 代码审查时关注新增 Flag 是否符合规范
  • 定期 Flag 使用报告(活跃数、过期数、无主数)

写在最后

Feature Flag 在架构演进里的价值,不是「让切换变快」,而是「让切换变安全」。它把一个大决策拆成很多小决策,每一步都可以停下来看看效果、退回去换个方向。

三个案例做下来,我有一个体会:架构演进的难度往往不在技术本身,而在「怎么让团队有信心推进」。Flag 给出的不只是回滚能力,还有一种「可控感」——知道出了问题能在分钟级别收场,工程师才敢放手去做。

但 Flag 本身也有成本。每多一个 Flag,代码就多一条分支,测试就多一个维度,运维就多一个配置。所以用完一定要清理。Flag 是脚手架,不是建筑物的一部分。楼盖好了,脚手架要拆掉。

我现在的习惯是:创建 Flag 的时候就想好怎么删它,就像写代码的时候就想好怎么测它。这个习惯帮我避免了很多「临时方案变永久架构」的问题。

参考资料

评论 0

0 / 1000