feat(feature-flag): Redis Feature Flag 灰度发布基础设施 #235

Merged
xiaoxia merged 6 commits from feat/feature-flag-redis into develop 2026-07-13 01:11:46 +08:00
Owner

背景

灰度发布需要支持动态切流和热更新,原环境变量方式需重启 worker 才能生效,无法满足灰度期间灵活调整比例的需求。

实现

1. FeatureFlagStore 核心(packages/adapters/redis/feature_flag_store.py)

  • 抽象接口FeatureFlagStore 基类,定义 get/set/delete/list_all/is_active
  • Redis 实现RedisFeatureFlagStore,hash 结构存储,本地缓存 5s TTL
  • 内存实现InMemoryFeatureFlagStore,测试/开发用
  • 三种 Flag 类型:全局开关 + 白名单 + 百分比切流(MD5哈希,同一用户始终一致)
  • 判定优先级:白名单 > 百分比 > 全局开关
  • 降级保护:Redis 不可用时自动返回默认值(关闭),不影响业务

2. RenderEngineResolver(apps/worker/video_processing/render_engine_resolver.py)

  • 渲染引擎专用选择器,封装灰度判定逻辑
  • 环境变量默认值 + Redis 运行时覆盖,30秒惰性刷新
  • 按 user_id 灰度(白名单命中 → 百分比命中 → 默认值)
  • 在途任务不受配置变更影响(任务开始时确定引擎,后续配置变化不改变当前任务)
  • 全局单例 get_render_engine_resolver()

3. 管理 API(apps/api/app/api/routes/feature_flags.py)

  • GET /api/v1/internal/feature-flags - 列出所有 flag
  • GET /api/v1/internal/feature-flags/{name} - 查看单个 flag
  • GET /api/v1/internal/feature-flags/{name}/check?identifier=xxx - 验证是否命中
  • PUT /api/v1/internal/feature-flags/{name} - 修改 flag 配置
  • DELETE /api/v1/internal/feature-flags/{name} - 删除 flag
  • 复用内部 API Key 鉴权(X-API-Key header)
  • ALLOWED_FLAGS 白名单限制,防止误操作系统其他 flag

4. compose_video 任务接入

  • 替换原环境变量直接读取方式
  • job.created_by_user_id 获取用户标识
  • 调用 RenderEngineResolver.get_engine(user_id) 选择引擎

测试

30 个单元测试全绿:

  • FeatureFlagConfig:默认值、全局开关、白名单优先级、百分比一致性、边界值(0/100)、序列化
  • InMemoryStore:CRUD、便捷方法
  • RenderEngineResolver:默认legacy、默认unified、白名单、100%全量、无效默认值降级、在途任务不受影响
  • Redis 降级:连接失败时返回默认值、list_all 返回空

加上原有 68 个渲染相关单测,合计 98 个全绿。

## 背景 灰度发布需要支持动态切流和热更新,原环境变量方式需重启 worker 才能生效,无法满足灰度期间灵活调整比例的需求。 ## 实现 ### 1. FeatureFlagStore 核心(packages/adapters/redis/feature_flag_store.py) - **抽象接口**:`FeatureFlagStore` 基类,定义 get/set/delete/list_all/is_active - **Redis 实现**:`RedisFeatureFlagStore`,hash 结构存储,本地缓存 5s TTL - **内存实现**:`InMemoryFeatureFlagStore`,测试/开发用 - **三种 Flag 类型**:全局开关 + 白名单 + 百分比切流(MD5哈希,同一用户始终一致) - **判定优先级**:白名单 > 百分比 > 全局开关 - **降级保护**:Redis 不可用时自动返回默认值(关闭),不影响业务 ### 2. RenderEngineResolver(apps/worker/video_processing/render_engine_resolver.py) - 渲染引擎专用选择器,封装灰度判定逻辑 - 环境变量默认值 + Redis 运行时覆盖,30秒惰性刷新 - 按 user_id 灰度(白名单命中 → 百分比命中 → 默认值) - 在途任务不受配置变更影响(任务开始时确定引擎,后续配置变化不改变当前任务) - 全局单例 `get_render_engine_resolver()` ### 3. 管理 API(apps/api/app/api/routes/feature_flags.py) - `GET /api/v1/internal/feature-flags` - 列出所有 flag - `GET /api/v1/internal/feature-flags/{name}` - 查看单个 flag - `GET /api/v1/internal/feature-flags/{name}/check?identifier=xxx` - 验证是否命中 - `PUT /api/v1/internal/feature-flags/{name}` - 修改 flag 配置 - `DELETE /api/v1/internal/feature-flags/{name}` - 删除 flag - 复用内部 API Key 鉴权(X-API-Key header) - ALLOWED_FLAGS 白名单限制,防止误操作系统其他 flag ### 4. compose_video 任务接入 - 替换原环境变量直接读取方式 - 从 `job.created_by_user_id` 获取用户标识 - 调用 `RenderEngineResolver.get_engine(user_id)` 选择引擎 ## 测试 30 个单元测试全绿: - FeatureFlagConfig:默认值、全局开关、白名单优先级、百分比一致性、边界值(0/100)、序列化 - InMemoryStore:CRUD、便捷方法 - RenderEngineResolver:默认legacy、默认unified、白名单、100%全量、无效默认值降级、在途任务不受影响 - Redis 降级:连接失败时返回默认值、list_all 返回空 加上原有 68 个渲染相关单测,合计 98 个全绿。
Author
Owner

审计结论 ⚠️ 有条件通过(1 P1 + 2 P2 + 2 P3)

变更概览

  • 文件:12 个(新增 feature_flag_store / render_engine_resolver / feature_flags API / 384 行单测)
  • 新增/删除:+1958 / -66
  • 单测:30 个 Feature Flag 测试 + 16 个 adapter 测试,全绿

P1-1:list_all 中 Redis SCAN 参数名错误(match → match_pattern)

位置packages/adapters/redis/feature_flag_store.py 第 246 行

cursor, keys = self._redis.scan(
    cursor=cursor, match_pattern=pattern, count=100  # ❌ match_pattern 不存在
)

问题:redis-py 的 scan() 方法参数名是 match,不是 match_patternmatch_pattern 会被 **kwargs 吞掉,等价于没有传过滤 pattern

影响

  • list_all() 会扫描整个 Redis 实例的所有 key,而不只是 feature_flag:* 前缀
  • 如果 Redis 里有大量其他 key(如 Celery 队列、缓存等),一次 list_all 可能遍历几十万 key,严重影响 Redis 性能
  • 生产环境可能导致 Redis 阻塞,影响所有依赖 Redis 的服务(Celery 任务、缓存等)
  • 这是一个隐蔽的性能炸弹,功能测试发现不了(功能上 list_all 只是返回了多余的 key,再过滤一遍而已)

修复建议

cursor, keys = self._redis.scan(
    cursor=cursor, match=pattern, count=100  # ✅ 改为 match
)

验证:调用 store.list_all() 后观察 Redis INFO statskeyspace_hits 是否异常增加,或直接 debug 打印实际 scan 返回的 key 数量。


P2-1:Redis 不可用时降级为"全部关闭",灰度期可能导致大面积回退

位置RedisFeatureFlagStore.get() 第 212 行,RenderEngineResolver.get_engine()

问题:Redis 连接失败时,get() 返回 FeatureFlagConfig(enabled=False)。然后 RenderEngineResolver.get_engine() 看到 enabled=False 就返回 self._default_engine(legacy)。

这意味着:

  • 如果 Redis 短暂抖动(网络闪断几秒),所有灰度流量会瞬间切回旧引擎
  • Redis 恢复后,又需要等缓存刷新(最多 5 秒 + refresh_interval 30 秒)才能恢复灰度
  • 灰度期间频繁的 Redis 抖动会导致引擎反复切换,用户体验不一致

建议

  1. 降级策略优化:Redis 不可用时,优先使用本地缓存中的旧配置,而不是直接返回默认值。当前实现中,如果缓存过期了且 Redis 挂了,确实会回落到默认值。可以考虑"缓存永不过期,Redis 挂了就一直用上次成功的配置"。
  2. RenderEngineResolver._maybe_refresh 中已经有类似逻辑(刷新失败保留旧缓存),这部分是对的。但 RedisFeatureFlagStore.get() 的首次失败降级太激进。
  3. 建议:_maybe_refresh 是对的(失败保留旧值),但 get() 作为底层接口也应该有同样的保护。当前架构下因为 resolver 层已经有缓存兜底,问题被缓解了,但 store 层的行为仍然不安全。

P2-2:API 修改配置后,Worker 侧有最多 30 秒延迟才生效

位置RenderEngineResolver._refresh_interval = 30.0

问题:管理 API 修改 flag 后,API 侧的 Redis 数据已经更新了,但 Worker 侧的 RenderEngineResolver 有 30 秒的刷新间隔,最长 30 秒后才会生效。

灰度发布时,管理员点了"开启灰度",可能要等半分钟才真正生效,体验不好;紧急回滚时也有同样的延迟。

建议

  1. 这不是 bug,但需要在文档和操作手册中明确说明"配置修改后 30 秒内生效"
  2. 或者实现主动通知机制(Redis Pub/Sub 通知所有 Worker 刷新配置)—— 这个可以做 Phase 2 优化
  3. 当前阶段至少要在 UI / API 响应中提示生效延迟

P3-1:set / delete 没有异常处理

位置RedisFeatureFlagStore.set() 第 218 行、delete() 第 232 行

问题getlist_all 都有 try-catch 降级,但 setdelete 没有。Redis 不可用时,管理 API 调用会直接 500。

建议set/delete 失败时返回明确的错误信息就好(不需要降级),但建议统一异常处理风格,给前端友好的错误提示。


P3-2:百分比哈希用 hashlib.md5 后取模,分布均匀性没问题,但 flag_name 参与哈希是对的

确认点hashlib.md5(f"{self.name}:{identifier}") — 不同的 flag 用同一个 identifier 会得到不同结果,这是正确的设计,防止各 feature flag 之间的命中互相影响。


验证通过的项

状态 说明
灰度切流逻辑 白名单 > 百分比 > 全局开关,优先级正确
热更新安全性 惰性刷新 + 锁保护,在途任务不受影响(任务开始时确定引擎,执行中不变)
API 鉴权 _verify_internal_api_key 走内部 API Key,生产环境未配置时拒绝
ALLOWED_FLAGS 白名单 只允许修改 render_engine,防止误操作其他 flag
降级容错 Redis 不可用时回落到默认引擎,不阻塞业务
单测覆盖 30 个单测覆盖配置/存储/resolver/降级各场景
默认值安全 默认 legacy,Redis 挂了也回 legacy
user_id 传递 job.created_by_user_id 正确传递给 resolver

总结

架构设计合理,降级策略正确,单测覆盖充分。P1(SCAN 参数错误)必须修,否则 list_all 接口是个性能炸弹。P2 建议优化但不阻塞上线,P3 后续迭代完善。

修复 P1 后审查通过,可合并。

## 审计结论 ⚠️ 有条件通过(1 P1 + 2 P2 + 2 P3) ### 变更概览 - **文件**:12 个(新增 feature_flag_store / render_engine_resolver / feature_flags API / 384 行单测) - **新增/删除**:+1958 / -66 - **单测**:30 个 Feature Flag 测试 + 16 个 adapter 测试,全绿 --- ### P1-1:`list_all` 中 Redis SCAN 参数名错误(match → match_pattern) **位置**:`packages/adapters/redis/feature_flag_store.py` 第 246 行 ```python cursor, keys = self._redis.scan( cursor=cursor, match_pattern=pattern, count=100 # ❌ match_pattern 不存在 ) ``` **问题**:redis-py 的 `scan()` 方法参数名是 `match`,不是 `match_pattern`。`match_pattern` 会被 `**kwargs` 吞掉,**等价于没有传过滤 pattern**。 **影响**: - `list_all()` 会扫描整个 Redis 实例的所有 key,而不只是 `feature_flag:*` 前缀 - 如果 Redis 里有大量其他 key(如 Celery 队列、缓存等),一次 list_all 可能遍历几十万 key,严重影响 Redis 性能 - 生产环境可能导致 Redis 阻塞,影响所有依赖 Redis 的服务(Celery 任务、缓存等) - 这是一个**隐蔽的性能炸弹**,功能测试发现不了(功能上 list_all 只是返回了多余的 key,再过滤一遍而已) **修复建议**: ```python cursor, keys = self._redis.scan( cursor=cursor, match=pattern, count=100 # ✅ 改为 match ) ``` **验证**:调用 `store.list_all()` 后观察 Redis `INFO stats` 中 `keyspace_hits` 是否异常增加,或直接 debug 打印实际 scan 返回的 key 数量。 --- ### P2-1:Redis 不可用时降级为"全部关闭",灰度期可能导致大面积回退 **位置**:`RedisFeatureFlagStore.get()` 第 212 行,`RenderEngineResolver.get_engine()` **问题**:Redis 连接失败时,`get()` 返回 `FeatureFlagConfig(enabled=False)`。然后 `RenderEngineResolver.get_engine()` 看到 `enabled=False` 就返回 `self._default_engine`(legacy)。 这意味着: - 如果 Redis 短暂抖动(网络闪断几秒),所有灰度流量会瞬间切回旧引擎 - Redis 恢复后,又需要等缓存刷新(最多 5 秒 + refresh_interval 30 秒)才能恢复灰度 - 灰度期间频繁的 Redis 抖动会导致引擎反复切换,用户体验不一致 **建议**: 1. 降级策略优化:Redis 不可用时,**优先使用本地缓存中的旧配置**,而不是直接返回默认值。当前实现中,如果缓存过期了且 Redis 挂了,确实会回落到默认值。可以考虑"缓存永不过期,Redis 挂了就一直用上次成功的配置"。 2. 在 `RenderEngineResolver._maybe_refresh` 中已经有类似逻辑(刷新失败保留旧缓存),这部分是对的。但 `RedisFeatureFlagStore.get()` 的首次失败降级太激进。 3. 建议:`_maybe_refresh` 是对的(失败保留旧值),但 `get()` 作为底层接口也应该有同样的保护。当前架构下因为 resolver 层已经有缓存兜底,问题被缓解了,但 store 层的行为仍然不安全。 --- ### P2-2:API 修改配置后,Worker 侧有最多 30 秒延迟才生效 **位置**:`RenderEngineResolver._refresh_interval = 30.0` **问题**:管理 API 修改 flag 后,API 侧的 Redis 数据已经更新了,但 Worker 侧的 `RenderEngineResolver` 有 30 秒的刷新间隔,最长 30 秒后才会生效。 灰度发布时,管理员点了"开启灰度",可能要等半分钟才真正生效,体验不好;紧急回滚时也有同样的延迟。 **建议**: 1. 这不是 bug,但需要在文档和操作手册中明确说明"配置修改后 30 秒内生效" 2. 或者实现主动通知机制(Redis Pub/Sub 通知所有 Worker 刷新配置)—— 这个可以做 Phase 2 优化 3. 当前阶段至少要在 UI / API 响应中提示生效延迟 --- ### P3-1:`set` / `delete` 没有异常处理 **位置**:`RedisFeatureFlagStore.set()` 第 218 行、`delete()` 第 232 行 **问题**:`get` 和 `list_all` 都有 try-catch 降级,但 `set` 和 `delete` 没有。Redis 不可用时,管理 API 调用会直接 500。 **建议**:`set`/`delete` 失败时返回明确的错误信息就好(不需要降级),但建议统一异常处理风格,给前端友好的错误提示。 --- ### P3-2:百分比哈希用 `hashlib.md5` 后取模,分布均匀性没问题,但 flag_name 参与哈希是对的 **确认点**:`hashlib.md5(f"{self.name}:{identifier}")` — 不同的 flag 用同一个 identifier 会得到不同结果,这是正确的设计,防止各 feature flag 之间的命中互相影响。✅ --- ### 验证通过的项 ✅ | 项 | 状态 | 说明 | |----|------|------| | 灰度切流逻辑 | ✅ | 白名单 > 百分比 > 全局开关,优先级正确 | | 热更新安全性 | ✅ | 惰性刷新 + 锁保护,在途任务不受影响(任务开始时确定引擎,执行中不变) | | API 鉴权 | ✅ | `_verify_internal_api_key` 走内部 API Key,生产环境未配置时拒绝 | | ALLOWED_FLAGS 白名单 | ✅ | 只允许修改 `render_engine`,防止误操作其他 flag | | 降级容错 | ✅ | Redis 不可用时回落到默认引擎,不阻塞业务 | | 单测覆盖 | ✅ | 30 个单测覆盖配置/存储/resolver/降级各场景 | | 默认值安全 | ✅ | 默认 legacy,Redis 挂了也回 legacy | | user_id 传递 | ✅ | `job.created_by_user_id` 正确传递给 resolver | --- ### 总结 架构设计合理,降级策略正确,单测覆盖充分。**P1(SCAN 参数错误)必须修**,否则 list_all 接口是个性能炸弹。P2 建议优化但不阻塞上线,P3 后续迭代完善。 **修复 P1 后审查通过,可合并。**
xiaoxia force-pushed feat/feature-flag-redis from 770e02357d to de7cabbb63 2026-07-12 21:57:50 +08:00 Compare
Author
Owner

P1 修复复审通过 (Comment #1493 复审)

验证项

  • match_pattern=patternmatch=pattern,参数名正确,redis-py 会正确传入 MATCH 选项
  • 新增 test_list_all_scan_with_match_param 单测:验证 scan 调用参数中 match 存在、match_pattern 不存在、且值为前缀+通配符
  • 单测还顺带验证了游标分页遍历、多 flag 返回的完整路径

结论

P1 已修复,可以合并。

注:原审计中的 2 个 P2 建议(Redis 抖动降级策略、配置生效延迟)仍保留为建议项,不阻塞合并。

**P1 修复复审通过 ✅(Comment #1493 复审)** ### 验证项 - ✅ `match_pattern=pattern` → `match=pattern`,参数名正确,redis-py 会正确传入 MATCH 选项 - ✅ 新增 `test_list_all_scan_with_match_param` 单测:验证 scan 调用参数中 `match` 存在、`match_pattern` 不存在、且值为前缀+通配符 - ✅ 单测还顺带验证了游标分页遍历、多 flag 返回的完整路径 ### 结论 P1 已修复,可以合并。 > 注:原审计中的 2 个 P2 建议(Redis 抖动降级策略、配置生效延迟)仍保留为建议项,不阻塞合并。
xiaoxia added 3 commits 2026-07-13 00:30:25 +08:00
- 新增 FeatureFlagStore 抽象 + Redis/InMemory 双实现
  - 支持全局开关 + 白名单 + 百分比切流(MD5哈希一致性)
  - 本地缓存 + TTL,减少 Redis 调用
  - Redis 不可用时自动降级,不影响业务

- 新增 RenderEngineResolver 渲染引擎选择器
  - 环境变量默认 + Redis 运行时覆盖,支持热更新
  - 按 user_id 灰度(白名单 > 百分比 > 默认值)
  - 惰性刷新,30秒刷新间隔
  - 在途任务不受配置变更影响(任务开始时确定引擎)

- 新增 /api/v1/internal/feature-flags 管理接口
  - GET 列表/详情、PUT 修改、DELETE 删除
  - /{name}/check 端点验证指定标识符是否命中
  - 复用内部 API Key 鉴权
  - ALLOWED_FLAGS 白名单防误操作

- compose_video 任务接入 RenderEngineResolver
  - 从 job.created_by_user_id 获取用户标识
  - 替换原环境变量直接读取方式

- 30 个单元测试全绿
  - FeatureFlagConfig 判定逻辑(优先级、边界、一致性)
  - InMemoryStore CRUD
  - RenderEngineResolver(白名单、百分比、默认值、降级、热更新不影响在途任务)
  - Redis 异常降级
- P1修复: RedisFeatureFlagStore.list_all 中 scan 参数名错误,导致 list_all 接口必失败
- 新增 TestRedisStoreListAll 测试类,验证 scan 调用参数名正确性
chore(format): fix black/isort after rebase to develop
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 34s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m50s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m14s
e02a7475bc
xiaoxia force-pushed feat/feature-flag-redis from de7cabbb63 to e02a7475bc 2026-07-13 00:30:25 +08:00 Compare
xiaoxia added 1 commit 2026-07-13 00:49:51 +08:00
fix(security): add nosec for bandit B324 md5 in feature flag
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 17s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m4s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m13s
2cdaa24735
xiaoxia added 1 commit 2026-07-13 00:52:29 +08:00
fix(format): black format nosec comment line length
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 52s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m4s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m1s
0b002ce50d
xiaoxia added 1 commit 2026-07-13 00:59:06 +08:00
fix(security): nosec on correct line for bandit B324
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m10s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m32s
b6ebee7574
xiaoxia merged commit bfe8bfe2da into develop 2026-07-13 01:11:46 +08:00
xiaoxia deleted branch feat/feature-flag-redis 2026-07-13 01:11:46 +08:00
Sign in to join this conversation.