feat(feature-flag): Redis Feature Flag 灰度发布基础设施 #235
Reference in New Issue
Block a user
Delete Branch "feat/feature-flag-redis"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
背景
灰度发布需要支持动态切流和热更新,原环境变量方式需重启 worker 才能生效,无法满足灰度期间灵活调整比例的需求。
实现
1. FeatureFlagStore 核心(packages/adapters/redis/feature_flag_store.py)
FeatureFlagStore基类,定义 get/set/delete/list_all/is_activeRedisFeatureFlagStore,hash 结构存储,本地缓存 5s TTLInMemoryFeatureFlagStore,测试/开发用2. RenderEngineResolver(apps/worker/video_processing/render_engine_resolver.py)
get_render_engine_resolver()3. 管理 API(apps/api/app/api/routes/feature_flags.py)
GET /api/v1/internal/feature-flags- 列出所有 flagGET /api/v1/internal/feature-flags/{name}- 查看单个 flagGET /api/v1/internal/feature-flags/{name}/check?identifier=xxx- 验证是否命中PUT /api/v1/internal/feature-flags/{name}- 修改 flag 配置DELETE /api/v1/internal/feature-flags/{name}- 删除 flag4. compose_video 任务接入
job.created_by_user_id获取用户标识RenderEngineResolver.get_engine(user_id)选择引擎测试
30 个单元测试全绿:
加上原有 68 个渲染相关单测,合计 98 个全绿。
审计结论 ⚠️ 有条件通过(1 P1 + 2 P2 + 2 P3)
变更概览
P1-1:
list_all中 Redis SCAN 参数名错误(match → match_pattern)位置:
packages/adapters/redis/feature_flag_store.py第 246 行问题:redis-py 的
scan()方法参数名是match,不是match_pattern。match_pattern会被**kwargs吞掉,等价于没有传过滤 pattern。影响:
list_all()会扫描整个 Redis 实例的所有 key,而不只是feature_flag:*前缀修复建议:
验证:调用
store.list_all()后观察 RedisINFO 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)。这意味着:
建议:
RenderEngineResolver._maybe_refresh中已经有类似逻辑(刷新失败保留旧缓存),这部分是对的。但RedisFeatureFlagStore.get()的首次失败降级太激进。_maybe_refresh是对的(失败保留旧值),但get()作为底层接口也应该有同样的保护。当前架构下因为 resolver 层已经有缓存兜底,问题被缓解了,但 store 层的行为仍然不安全。P2-2:API 修改配置后,Worker 侧有最多 30 秒延迟才生效
位置:
RenderEngineResolver._refresh_interval = 30.0问题:管理 API 修改 flag 后,API 侧的 Redis 数据已经更新了,但 Worker 侧的
RenderEngineResolver有 30 秒的刷新间隔,最长 30 秒后才会生效。灰度发布时,管理员点了"开启灰度",可能要等半分钟才真正生效,体验不好;紧急回滚时也有同样的延迟。
建议:
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 之间的命中互相影响。✅验证通过的项 ✅
_verify_internal_api_key走内部 API Key,生产环境未配置时拒绝render_engine,防止误操作其他 flagjob.created_by_user_id正确传递给 resolver总结
架构设计合理,降级策略正确,单测覆盖充分。P1(SCAN 参数错误)必须修,否则 list_all 接口是个性能炸弹。P2 建议优化但不阻塞上线,P3 后续迭代完善。
修复 P1 后审查通过,可合并。
770e02357dtode7cabbb63P1 修复复审通过 ✅(Comment #1493 复审)
验证项
match_pattern=pattern→match=pattern,参数名正确,redis-py 会正确传入 MATCH 选项test_list_all_scan_with_match_param单测:验证 scan 调用参数中match存在、match_pattern不存在、且值为前缀+通配符结论
P1 已修复,可以合并。
- 新增 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 异常降级de7cabbb63toe02a7475bc