bfe8bfe2da
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m14s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m7s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m35s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 7m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m21s
Redis Feature Flag 灰度发布基础设施:白名单+百分比切流+全局开关,热更新,内部管理API
260 lines
8.3 KiB
Python
Executable File
260 lines
8.3 KiB
Python
Executable File
"""Feature Flag 存储实现。
|
||
|
||
支持两种后端:
|
||
- RedisFeatureFlagStore:生产环境使用,支持多实例共享、热更新
|
||
- InMemoryFeatureFlagStore:测试/开发环境使用,纯内存
|
||
|
||
支持的 Flag 类型:
|
||
- 全局开关(enabled: bool)
|
||
- 白名单(whitelist: Set[str],如 user_id 列表)
|
||
- 百分比切流(percentage: 0-100,基于标识符哈希取模)
|
||
|
||
判定优先级:白名单 > 百分比 > 全局开关
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import threading
|
||
import time
|
||
from abc import ABC, abstractmethod
|
||
from dataclasses import dataclass, field
|
||
from typing import Optional, Set
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Redis key 前缀
|
||
FEATURE_FLAG_REDIS_PREFIX = "feature_flag:"
|
||
|
||
|
||
@dataclass
|
||
class FeatureFlagConfig:
|
||
"""单个 Feature Flag 的配置。"""
|
||
|
||
name: str
|
||
enabled: bool = False
|
||
percentage: int = 0 # 0-100
|
||
whitelist: Set[str] = field(default_factory=set)
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"name": self.name,
|
||
"enabled": self.enabled,
|
||
"percentage": self.percentage,
|
||
"whitelist": sorted(self.whitelist),
|
||
}
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: dict) -> "FeatureFlagConfig":
|
||
return cls(
|
||
name=data["name"],
|
||
enabled=bool(data.get("enabled", False)),
|
||
percentage=int(data.get("percentage", 0)),
|
||
whitelist=set(data.get("whitelist", [])),
|
||
)
|
||
|
||
def is_active(self, identifier: Optional[str] = None) -> bool:
|
||
"""判断当前 flag 是否激活。
|
||
|
||
判定优先级:
|
||
1. 全局关闭 → False
|
||
2. 白名单匹配 → True
|
||
3. 百分比命中 → True
|
||
4. 其他 → False
|
||
|
||
Args:
|
||
identifier: 用于白名单匹配和百分比哈希的标识符(如 user_id)。
|
||
传 None 时只看全局开关 + 百分比(百分比用随机值)。
|
||
"""
|
||
if not self.enabled:
|
||
return False
|
||
|
||
# 白名单:精确匹配
|
||
if identifier and identifier in self.whitelist:
|
||
return True
|
||
|
||
# 百分比:0 直接 False,100 直接 True
|
||
if self.percentage <= 0:
|
||
# 没有白名单且百分比为0 → 未启用
|
||
return False
|
||
if self.percentage >= 100:
|
||
return True
|
||
|
||
# 基于 identifier 做哈希取模,确保同一用户始终落在同一侧
|
||
if identifier:
|
||
hash_val = int(
|
||
hashlib.md5(f"{self.name}:{identifier}".encode("utf-8")).hexdigest(), 16 # nosec B324
|
||
) # nosec B324 - 用于哈希取模做百分比切流,非安全用途
|
||
return (hash_val % 100) < self.percentage
|
||
|
||
# 无 identifier 且百分比在 0-100 之间 → 按比例随机(不保证一致性)
|
||
import random
|
||
|
||
return random.randint(0, 99) < self.percentage
|
||
|
||
|
||
class FeatureFlagStore(ABC):
|
||
"""Feature Flag 存储抽象接口。"""
|
||
|
||
@abstractmethod
|
||
def get(self, name: str) -> FeatureFlagConfig:
|
||
"""获取指定 flag 的配置,不存在则返回默认配置(关闭状态)。"""
|
||
...
|
||
|
||
@abstractmethod
|
||
def set(self, config: FeatureFlagConfig) -> None:
|
||
"""设置 flag 配置。"""
|
||
...
|
||
|
||
@abstractmethod
|
||
def delete(self, name: str) -> bool:
|
||
"""删除 flag,返回是否成功删除。"""
|
||
...
|
||
|
||
@abstractmethod
|
||
def list_all(self) -> dict[str, FeatureFlagConfig]:
|
||
"""列出所有 flag。"""
|
||
...
|
||
|
||
def is_active(self, name: str, identifier: Optional[str] = None) -> bool:
|
||
"""便捷方法:判断 flag 是否激活。"""
|
||
return self.get(name).is_active(identifier)
|
||
|
||
|
||
class InMemoryFeatureFlagStore(FeatureFlagStore):
|
||
"""内存实现,用于测试和本地开发。"""
|
||
|
||
def __init__(self) -> None:
|
||
self._flags: dict[str, FeatureFlagConfig] = {}
|
||
self._lock = threading.Lock()
|
||
|
||
def get(self, name: str) -> FeatureFlagConfig:
|
||
with self._lock:
|
||
return self._flags.get(name, FeatureFlagConfig(name=name, enabled=False))
|
||
|
||
def set(self, config: FeatureFlagConfig) -> None:
|
||
with self._lock:
|
||
self._flags[config.name] = config
|
||
|
||
def delete(self, name: str) -> bool:
|
||
with self._lock:
|
||
if name in self._flags:
|
||
del self._flags[name]
|
||
return True
|
||
return False
|
||
|
||
def list_all(self) -> dict[str, FeatureFlagConfig]:
|
||
with self._lock:
|
||
return dict(self._flags)
|
||
|
||
|
||
class RedisFeatureFlagStore(FeatureFlagStore):
|
||
"""Redis 实现,支持多实例共享配置。
|
||
|
||
每个 flag 存在一个独立的 Redis hash key 中:
|
||
Key: feature_flag:{name}
|
||
Fields: enabled, percentage, whitelist(JSON array)
|
||
"""
|
||
|
||
def __init__(self, redis_url: str, key_prefix: str = FEATURE_FLAG_REDIS_PREFIX) -> None:
|
||
import redis as redis_lib
|
||
|
||
self._redis = redis_lib.from_url(redis_url, decode_responses=True)
|
||
self._key_prefix = key_prefix
|
||
# 本地缓存 + TTL,减少 Redis 调用
|
||
self._cache: dict[str, tuple[FeatureFlagConfig, float]] = {}
|
||
self._cache_ttl = 5.0 # 秒,默认5秒本地缓存
|
||
self._lock = threading.Lock()
|
||
|
||
def _redis_key(self, name: str) -> str:
|
||
return f"{self._key_prefix}{name}"
|
||
|
||
def _parse_whitelist(self, raw: Optional[str]) -> Set[str]:
|
||
if not raw:
|
||
return set()
|
||
try:
|
||
data = json.loads(raw)
|
||
return set(data) if isinstance(data, list) else set()
|
||
except (json.JSONDecodeError, TypeError):
|
||
return set()
|
||
|
||
def get(self, name: str) -> FeatureFlagConfig:
|
||
now = time.time()
|
||
|
||
# 先查本地缓存
|
||
with self._lock:
|
||
cached = self._cache.get(name)
|
||
if cached and now - cached[1] < self._cache_ttl:
|
||
return cached[0]
|
||
|
||
# 从 Redis 读取
|
||
try:
|
||
key = self._redis_key(name)
|
||
data = self._redis.hgetall(key)
|
||
if not data:
|
||
config = FeatureFlagConfig(name=name, enabled=False)
|
||
else:
|
||
config = FeatureFlagConfig(
|
||
name=name,
|
||
enabled=(data.get("enabled", "0") in ("1", "true", "True")),
|
||
percentage=int(data.get("percentage", 0)),
|
||
whitelist=self._parse_whitelist(data.get("whitelist")),
|
||
)
|
||
|
||
# 写入本地缓存
|
||
with self._lock:
|
||
self._cache[name] = (config, now)
|
||
|
||
return config
|
||
except Exception as exc:
|
||
logger.warning("Failed to get feature flag %s from Redis: %s", name, exc)
|
||
# Redis 不可用时返回默认值(关闭),不影响业务
|
||
return FeatureFlagConfig(name=name, enabled=False)
|
||
|
||
def set(self, config: FeatureFlagConfig) -> None:
|
||
key = self._redis_key(config.name)
|
||
self._redis.hset(
|
||
key,
|
||
mapping={
|
||
"enabled": "1" if config.enabled else "0",
|
||
"percentage": str(config.percentage),
|
||
"whitelist": json.dumps(sorted(config.whitelist), ensure_ascii=False),
|
||
},
|
||
)
|
||
# 失效本地缓存
|
||
with self._lock:
|
||
self._cache.pop(config.name, None)
|
||
|
||
def delete(self, name: str) -> bool:
|
||
key = self._redis_key(name)
|
||
result = self._redis.delete(key)
|
||
with self._lock:
|
||
self._cache.pop(name, None)
|
||
return bool(result)
|
||
|
||
def list_all(self) -> dict[str, FeatureFlagConfig]:
|
||
pattern = f"{self._key_prefix}*"
|
||
result: dict[str, FeatureFlagConfig] = {}
|
||
try:
|
||
cursor = 0
|
||
while True:
|
||
cursor, keys = self._redis.scan(cursor=cursor, match=pattern, count=100)
|
||
for key in keys:
|
||
name = key[len(self._key_prefix) :]
|
||
result[name] = self.get(name)
|
||
if cursor == 0:
|
||
break
|
||
except Exception as exc:
|
||
logger.warning("Failed to list feature flags from Redis: %s", exc)
|
||
return result
|
||
|
||
def invalidate_cache(self, name: Optional[str] = None) -> None:
|
||
"""手动失效本地缓存。"""
|
||
with self._lock:
|
||
if name:
|
||
self._cache.pop(name, None)
|
||
else:
|
||
self._cache.clear()
|