Commit 8b74784e101f65588c792ebaf1261df74d715740
1 parent
6f7840cf
cache manage
Showing
5 changed files
with
755 additions
and
6 deletions
Show diff stats
docs/DEVELOPER_GUIDE.md
| @@ -423,6 +423,7 @@ services: | @@ -423,6 +423,7 @@ services: | ||
| 423 | | 向量模块与 clip-as-service | [embeddings/README.md](../embeddings/README.md) | | 423 | | 向量模块与 clip-as-service | [embeddings/README.md](../embeddings/README.md) | |
| 424 | | TEI 服务专项(安装/部署/GPU-CPU 模式) | [TEI_SERVICE说明文档.md](./TEI_SERVICE说明文档.md) | | 424 | | TEI 服务专项(安装/部署/GPU-CPU 模式) | [TEI_SERVICE说明文档.md](./TEI_SERVICE说明文档.md) | |
| 425 | | CN-CLIP 服务专项(环境/运维/GPU) | [CNCLIP_SERVICE说明文档.md](./CNCLIP_SERVICE说明文档.md) | | 425 | | CN-CLIP 服务专项(环境/运维/GPU) | [CNCLIP_SERVICE说明文档.md](./CNCLIP_SERVICE说明文档.md) | |
| 426 | +| 缓存 / Redis 使用与 key 设计 | [缓存与Redis使用说明.md](./缓存与Redis使用说明.md) | | ||
| 426 | 427 | ||
| 427 | ### 10.2 仓库内入口 | 428 | ### 10.2 仓库内入口 |
| 428 | 429 |
| @@ -0,0 +1,347 @@ | @@ -0,0 +1,347 @@ | ||
| 1 | +## 缓存与 Redis 使用说明 | ||
| 2 | + | ||
| 3 | +本仓库中 Redis 主要用于**性能型缓存**,当前落地的业务缓存包括: | ||
| 4 | + | ||
| 5 | +- **文本向量缓存**(embedding 缓存) | ||
| 6 | +- **翻译结果缓存**(Qwen-MT 等机器翻译) | ||
| 7 | +- **商品内容理解缓存**(锚文本 / 语义属性 / 标签) | ||
| 8 | + | ||
| 9 | +底层连接配置统一来自 `config/env_config.py` 的 `REDIS_CONFIG`: | ||
| 10 | + | ||
| 11 | +- **Host/Port**:`REDIS_HOST` / `REDIS_PORT`(默认 `localhost:6479`) | ||
| 12 | +- **Password**:`REDIS_PASSWORD` | ||
| 13 | +- **Socket & 超时**:`REDIS_SOCKET_TIMEOUT` / `REDIS_SOCKET_CONNECT_TIMEOUT` / `REDIS_RETRY_ON_TIMEOUT` | ||
| 14 | +- **通用缓存 TTL**:`REDIS_CACHE_EXPIRE_DAYS`(默认 `360*2` 天,代码注释为 “6 months”) | ||
| 15 | +- **翻译缓存 TTL & 前缀**:`REDIS_TRANSLATION_CACHE_EXPIRE_DAYS`、`REDIS_TRANSLATION_CACHE_PREFIX` | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## 1. 缓存总览表 | ||
| 20 | + | ||
| 21 | +| 模块 / 场景 | Key 模板 | Value 内容示例 | 过期策略 | 备注 | | ||
| 22 | +|------------|----------|----------------|----------|------| | ||
| 23 | +| 文本向量缓存(embedding) | `embedding:{language}:{norm_flag}:{query}` | `pickle.dumps(np.ndarray)`,如 1024 维 BGE 向量 | TTL=`REDIS_CONFIG["cache_expire_days"]` 天;访问时滑动过期 | 见 `embeddings/text_encoder.py` | | ||
| 24 | +| 翻译结果缓存(Qwen-MT 翻译) | `{cache_prefix}:{model}:{src}:{tgt}:{sha256(payload)}` | 机翻后的单条字符串 | TTL=`services.translation.cache.ttl_seconds` 秒;可配置滑动过期 | 见 `query/qwen_mt_translate.py` + `config/config.yaml` | | ||
| 25 | +| 商品内容理解缓存(anchors / 语义属性 / tags) | `{ANCHOR_CACHE_PREFIX}:{tenant_or_global}:{target_lang}:{md5(title)}` | `json.dumps(dict)`,包含 id/title/category/tags/anchor_text 等 | TTL=`ANCHOR_CACHE_EXPIRE_DAYS` 天 | 见 `indexer/product_enrich.py` | | ||
| 26 | + | ||
| 27 | +下面按模块详细说明。 | ||
| 28 | + | ||
| 29 | +--- | ||
| 30 | + | ||
| 31 | +## 2. 文本向量缓存(embeddings/text_encoder.py) | ||
| 32 | + | ||
| 33 | +- **代码位置**:`embeddings/text_encoder.py` 中 `TextEmbeddingEncoder` | ||
| 34 | +- **用途**:缓存调用向量服务(6005)的文本向量结果,避免重复计算。 | ||
| 35 | + | ||
| 36 | +### 2.1 Key 设计 | ||
| 37 | + | ||
| 38 | +- 函数:`_get_cache_key(query: str, language: str, normalize_embeddings: bool) -> str` | ||
| 39 | +- 模板: | ||
| 40 | + | ||
| 41 | +```text | ||
| 42 | +embedding:{language}:{norm_flag}:{query} | ||
| 43 | +``` | ||
| 44 | + | ||
| 45 | +- 字段说明: | ||
| 46 | + - `language`:当前实现中统一传入 `"generic"`; | ||
| 47 | + - `norm_flag`:`"norm1"` 表示归一化向量,`"norm0"` 表示未归一化; | ||
| 48 | + - `query`:原始文本(未做哈希),注意长度特别长的 query 会直接出现在 key 中。 | ||
| 49 | + | ||
| 50 | +### 2.2 Value 与类型 | ||
| 51 | + | ||
| 52 | +- 类型:`pickle.dumps(np.ndarray)`,在读取时通过 `pickle.loads` 还原为 `np.ndarray`。 | ||
| 53 | +- 典型示例:BGE-M3 1024 维 `float32` 向量。 | ||
| 54 | + | ||
| 55 | +### 2.3 过期策略 | ||
| 56 | + | ||
| 57 | +- 初始化: | ||
| 58 | + - `self.expire_time = timedelta(days=REDIS_CONFIG.get("cache_expire_days", 180))` | ||
| 59 | + - `.env` 中可通过 `REDIS_CACHE_EXPIRE_DAYS` 配置,默认 `360*2`(代码注释标注为 6 个月)。 | ||
| 60 | +- 写入: | ||
| 61 | + - `redis.setex(cache_key, self.expire_time, serialized_data)` | ||
| 62 | +- 访问(滑动过期): | ||
| 63 | + - 命中缓存后,会调用 `redis.expire(cache_key, self.expire_time)` 延长 TTL。 | ||
| 64 | + | ||
| 65 | +### 2.4 特殊处理 | ||
| 66 | + | ||
| 67 | +- 若缓存中的向量 **为空 / shape 异常 / 含 NaN/Inf**,会: | ||
| 68 | + - 直接丢弃该缓存(并尝试 `delete` key); | ||
| 69 | + - 回退为重新调用向量服务。 | ||
| 70 | + | ||
| 71 | +--- | ||
| 72 | + | ||
| 73 | +## 3. 翻译结果缓存(query/qwen_mt_translate.py) | ||
| 74 | + | ||
| 75 | +- **代码位置**:`query/qwen_mt_translate.py` 中 `Translator` 类 | ||
| 76 | +- **用途**:缓存 Qwen-MT 翻译(及 translator service 复用的翻译)结果,减少云端请求,遵守限速。 | ||
| 77 | +- **配置入口**:`config/config.yaml -> services.translation.cache`,统一由 `config/services_config.get_translation_cache_config()` 解析。 | ||
| 78 | + | ||
| 79 | +### 3.1 Key 设计 | ||
| 80 | + | ||
| 81 | +- 内部构造函数:`_build_cache_key(...)` | ||
| 82 | +- 模板: | ||
| 83 | + | ||
| 84 | +```text | ||
| 85 | +{cache_prefix}:{model}:{src}:{tgt}:{sha256(payload)} | ||
| 86 | +``` | ||
| 87 | + | ||
| 88 | +其中: | ||
| 89 | + | ||
| 90 | +- `cache_prefix`:来自 `services.translation.cache.key_prefix`,默认 `trans:v2`; | ||
| 91 | +- `model`:如 `"qwen-mt"`; | ||
| 92 | +- `src`:源语言(如 `zh` / `en` / `auto`),是否包含在 key 中由 `key_include_source_lang` 控制; | ||
| 93 | +- `tgt`:目标语言,如 `en` / `zh`; | ||
| 94 | +- `sha256(payload)`:对以下内容整体做 SHA-256: | ||
| 95 | + - `model` | ||
| 96 | + - `src` / `tgt` | ||
| 97 | + - `context`(受 `key_include_context` 控制) | ||
| 98 | + - `prompt`(受 `key_include_prompt` 控制) | ||
| 99 | + - 原始 `text` | ||
| 100 | + | ||
| 101 | +> 注意:所有 key 设计集中在 `_build_cache_key`,**不要在其他位置手动拼翻译缓存 key**。 | ||
| 102 | + | ||
| 103 | +### 3.2 Value 与类型 | ||
| 104 | + | ||
| 105 | +- 类型:**UTF-8 字符串**,即翻译后的文本结果。 | ||
| 106 | +- 存取逻辑: | ||
| 107 | + - 读取:`redis.get(key)` 返回 `str` 或 `None`; | ||
| 108 | + - 写入:`redis.setex(key, expire_seconds, translation)`。 | ||
| 109 | + | ||
| 110 | +### 3.3 过期策略 | ||
| 111 | + | ||
| 112 | +- 配置来源:`config/config.yaml -> services.translation.cache`,经 `get_translation_cache_config()` 解析: | ||
| 113 | + | ||
| 114 | +```yaml | ||
| 115 | +services: | ||
| 116 | + translation: | ||
| 117 | + cache: | ||
| 118 | + enabled: true | ||
| 119 | + key_prefix: "trans:v2" | ||
| 120 | + ttl_seconds: 62208000 # 默认约 720 天 | ||
| 121 | + sliding_expiration: true | ||
| 122 | + key_include_context: true | ||
| 123 | + key_include_prompt: true | ||
| 124 | + key_include_source_lang: true | ||
| 125 | +``` | ||
| 126 | + | ||
| 127 | +- 运行时行为: | ||
| 128 | + - 创建 `Translator` 时,从 `cache_cfg` 读取: | ||
| 129 | + - `self.cache_prefix` | ||
| 130 | + - `self.expire_seconds` | ||
| 131 | + - `self.cache_sliding_expiration` | ||
| 132 | + - `self.cache_include_*` 一系列布尔开关; | ||
| 133 | + - **读缓存**: | ||
| 134 | + - 命中后,若 `sliding_expiration=True`,会调用 `redis.expire(key, expire_seconds)`; | ||
| 135 | + - **写缓存**: | ||
| 136 | + - 使用 `redis.setex(key, expire_seconds, translation)`。 | ||
| 137 | + | ||
| 138 | +### 3.4 关联模块 | ||
| 139 | + | ||
| 140 | +- `api/translator_app.py` 会通过 `query.qwen_mt_translate.Translator` 复用同一套缓存逻辑; | ||
| 141 | +- 文档说明:`docs/翻译模块说明.md` 中提到“推荐通过 Redis 翻译缓存复用结果”。 | ||
| 142 | + | ||
| 143 | +--- | ||
| 144 | + | ||
| 145 | +## 4. 商品内容理解缓存(indexer/product_enrich.py) | ||
| 146 | + | ||
| 147 | +- **代码位置**:`indexer/product_enrich.py` | ||
| 148 | +- **用途**:在生成商品锚文本(qanchors)、语义属性、标签等内容理解结果时复用缓存,避免对同一标题重复调用大模型。 | ||
| 149 | + | ||
| 150 | +### 4.1 Key 设计 | ||
| 151 | + | ||
| 152 | +- 配置项: | ||
| 153 | + - `ANCHOR_CACHE_PREFIX = REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors")` | ||
| 154 | + - `ANCHOR_CACHE_EXPIRE_DAYS = int(REDIS_CONFIG.get("anchor_cache_expire_days", 30))` | ||
| 155 | +- Key 构造函数:`_make_anchor_cache_key(title, target_lang, tenant_id)` | ||
| 156 | +- 模板: | ||
| 157 | + | ||
| 158 | +```text | ||
| 159 | +{ANCHOR_CACHE_PREFIX}:{tenant_or_global}:{target_lang}:{md5(title)} | ||
| 160 | +``` | ||
| 161 | + | ||
| 162 | +- 字段说明: | ||
| 163 | + - `ANCHOR_CACHE_PREFIX`:默认 `"product_anchors"`,可通过 `.env` 中的 `REDIS_ANCHOR_CACHE_PREFIX`(若存在)间接配置到 `REDIS_CONFIG`; | ||
| 164 | + - `tenant_or_global`:`tenant_id` 去空白后的字符串,若为空则使用 `"global"`; | ||
| 165 | + - `target_lang`:内容理解输出语言,例如 `zh`; | ||
| 166 | + - `md5(title)`:对原始商品标题(UTF-8)做 MD5。 | ||
| 167 | + | ||
| 168 | +### 4.2 Value 与类型 | ||
| 169 | + | ||
| 170 | +- 类型:`json.dumps(dict, ensure_ascii=False)`。 | ||
| 171 | +- 典型结构(简化): | ||
| 172 | + | ||
| 173 | +```json | ||
| 174 | +{ | ||
| 175 | + "id": "123", | ||
| 176 | + "lang": "zh", | ||
| 177 | + "title_input": "原始标题", | ||
| 178 | + "title": "归一化后的商品标题", | ||
| 179 | + "category_path": "...", | ||
| 180 | + "tags": "...", | ||
| 181 | + "target_audience": "...", | ||
| 182 | + "usage_scene": "...", | ||
| 183 | + "anchor_text": "..., ..." | ||
| 184 | +} | ||
| 185 | +``` | ||
| 186 | + | ||
| 187 | +- 读取时通过 `json.loads(raw)` 还原为 `Dict[str, Any]`。 | ||
| 188 | + | ||
| 189 | +### 4.3 过期策略 | ||
| 190 | + | ||
| 191 | +- TTL:`ttl = ANCHOR_CACHE_EXPIRE_DAYS * 24 * 3600` 秒(默认 30 天); | ||
| 192 | +- 写入:`redis.setex(key, ttl, json.dumps(result, ensure_ascii=False))`; | ||
| 193 | +- 读取:仅做 `redis.get(key)`,**不做滑动过期**。 | ||
| 194 | + | ||
| 195 | +### 4.4 调用流程中的位置 | ||
| 196 | + | ||
| 197 | +- 单条调用(索引阶段常见)时,`analyze_products()` 会先尝试命中缓存: | ||
| 198 | + - 若命中,直接返回缓存结果; | ||
| 199 | + - 若 miss,调用 LLM,解析结果后再写入缓存。 | ||
| 200 | + | ||
| 201 | +--- | ||
| 202 | + | ||
| 203 | +## 5. Redis 运维脚本工具 | ||
| 204 | + | ||
| 205 | +`scripts/redis/` 下提供三个脚本,用于查看缓存数量、内存占用与健康状态。连接配置均来自 `config/env_config.py` 的 `REDIS_CONFIG`,运行前需在项目根目录执行(或保证 `PYTHONPATH` 含项目根),以便加载配置。 | ||
| 206 | + | ||
| 207 | +### 5.1 redis_cache_health_check.py(缓存健康巡检) | ||
| 208 | + | ||
| 209 | +**功能**:按**业务缓存类型**(embedding / translation / anchors)做健康巡检,不扫全库。 | ||
| 210 | + | ||
| 211 | +- 对每类缓存:SCAN 匹配对应 key 前缀,统计**匹配 key 数量**(受 `--max-scan` 上限约束); | ||
| 212 | +- **TTL 分布**:对采样 key 统计 `no-expire-or-expired` / `0-1h` / `1h-1d` / `1d-30d` / `>30d`; | ||
| 213 | +- **近期活跃 key**:从采样中选出 `OBJECT IDLETIME <= 600s` 的 key,用于判断是否有新写入; | ||
| 214 | +- **样本 key 与 value 预览**:对 embedding 显示 ndarray 信息,对 translation 显示译文片段,对 anchors 显示 JSON 摘要。 | ||
| 215 | + | ||
| 216 | +**适用场景**:日常查看三类缓存是否在增长、TTL 是否合理、是否有近期写入;与「缓存总览表」中的 key 设计一一对应。 | ||
| 217 | + | ||
| 218 | +**用法示例**: | ||
| 219 | + | ||
| 220 | +```bash | ||
| 221 | +# 默认:检查 embedding / translation / anchors 三类 | ||
| 222 | +python scripts/redis/redis_cache_health_check.py | ||
| 223 | + | ||
| 224 | +# 只检查某一类或两类 | ||
| 225 | +python scripts/redis/redis_cache_health_check.py --type embedding | ||
| 226 | +python scripts/redis/redis_cache_health_check.py --type translation anchors | ||
| 227 | + | ||
| 228 | +# 按自定义 pattern 检查(不按业务类型) | ||
| 229 | +python scripts/redis/redis_cache_health_check.py --pattern "mycache:*" | ||
| 230 | + | ||
| 231 | +# 调整采样与扫描规模 | ||
| 232 | +python scripts/redis/redis_cache_health_check.py --sample-size 100 --max-scan 50000 --db 0 | ||
| 233 | +``` | ||
| 234 | + | ||
| 235 | +**常用参数**: | ||
| 236 | + | ||
| 237 | +| 参数 | 说明 | 默认 | | ||
| 238 | +|------|------|------| | ||
| 239 | +| `--type` | 缓存类型:`embedding` / `translation` / `anchors`,可多选 | 三类都检查 | | ||
| 240 | +| `--pattern` | 自定义 key pattern(如 `mycache:*`),指定后忽略 `--type` | - | | ||
| 241 | +| `--db` | Redis 数据库编号 | 0 | | ||
| 242 | +| `--sample-size` | 每类采样的 key 数量 | 50 | | ||
| 243 | +| `--max-scan` | 每类最多 SCAN 的 key 数量上限 | 20000 | | ||
| 244 | + | ||
| 245 | +--- | ||
| 246 | + | ||
| 247 | +### 5.2 redis_cache_prefix_stats.py(按前缀统计条数与内存) | ||
| 248 | + | ||
| 249 | +**功能**:**全局视角**,扫描当前 DB 下所有 key,按 key 的**前缀**(第一个冒号前)分类,统计每类 key 的**条数**与**内存占用量**(含占比),并输出每类示例 key 与 Redis 总内存信息。 | ||
| 250 | + | ||
| 251 | +- 使用 `SCAN` 扫全库,按前缀聚合; | ||
| 252 | +- 内存优先用 Redis `MEMORY USAGE`,不可用时用 key+value 长度估算;key 过多时按 `--sample-size` 采样后按均值推算总内存; | ||
| 253 | +- 输出:前缀、条数、内存及计算方式、占比、简要说明(如「翻译缓存」「向量化缓存」);末尾附 Redis 总内存与 maxmemory(若配置)。 | ||
| 254 | + | ||
| 255 | +**适用场景**:了解「哪一类前缀占了多少条、多少内存」,做容量规划或清理决策;支持多 DB(`--all-db`)或指定前缀(`--prefix`)缩小范围。 | ||
| 256 | + | ||
| 257 | +**用法示例**: | ||
| 258 | + | ||
| 259 | +```bash | ||
| 260 | +# 默认 DB 0,全库按前缀统计 | ||
| 261 | +python scripts/redis/redis_cache_prefix_stats.py | ||
| 262 | + | ||
| 263 | +# 统计所有有数据的 DB | ||
| 264 | +python scripts/redis/redis_cache_prefix_stats.py --all-db | ||
| 265 | + | ||
| 266 | +# 指定 DB | ||
| 267 | +python scripts/redis/redis_cache_prefix_stats.py --db 1 | ||
| 268 | + | ||
| 269 | +# 只统计指定前缀(可多个) | ||
| 270 | +python scripts/redis/redis_cache_prefix_stats.py --prefix trans embedding product_anchors | ||
| 271 | + | ||
| 272 | +# 全 DB + 指定前缀 | ||
| 273 | +python scripts/redis/redis_cache_prefix_stats.py --all-db --prefix trans embedding | ||
| 274 | +``` | ||
| 275 | + | ||
| 276 | +**常用参数**: | ||
| 277 | + | ||
| 278 | +| 参数 | 说明 | 默认 | | ||
| 279 | +|------|------|------| | ||
| 280 | +| `--prefix` | 只统计这些前缀下的 key(如 `trans` `embedding`) | 全库按前缀统计 | | ||
| 281 | +| `--db` | 数据库编号(0–15) | 0 | | ||
| 282 | +| `--all-db` | 对所有有数据的 DB 分别执行 | 否 | | ||
| 283 | +| `--sample-size` | 单前缀 key 过多时,用于内存采样的数量 | 100 | | ||
| 284 | +| `--real` | 对单前缀 key 数 ≤10000 时计算全部 key 真实内存(较慢) | 否 | | ||
| 285 | + | ||
| 286 | +--- | ||
| 287 | + | ||
| 288 | +### 5.3 redis_memory_heavy_keys.py(大 key / 内存占用排查) | ||
| 289 | + | ||
| 290 | +**功能**:找出当前 DB 中**占用内存最多的 key**,并分析「按 key 估算的总内存」与「Redis 实际使用内存」的差异原因。 | ||
| 291 | + | ||
| 292 | +- 全库 `SCAN` 获取 key 列表,对 key 采样(默认最多 1000)调用 `MEMORY USAGE`(或估算)得到单 key 内存; | ||
| 293 | +- 按内存排序,输出**占用最高的 N 个 key**(`--top`,默认 50); | ||
| 294 | +- 输出:前缀分布、采样统计、估算总内存 vs 实际内存、差异说明(碎片、内部结构等);并检测超大 value(>1MB)、key 类型分布。 | ||
| 295 | + | ||
| 296 | +**适用场景**:内存异常升高时定位大 key;理解为何「按 key 加总」与 `used_memory` 不一致。 | ||
| 297 | + | ||
| 298 | +**用法示例**: | ||
| 299 | + | ||
| 300 | +```bash | ||
| 301 | +# 显示占用内存最多的 50 个 key(默认) | ||
| 302 | +python scripts/redis/redis_memory_heavy_keys.py | ||
| 303 | + | ||
| 304 | +# 显示前 100 个 | ||
| 305 | +python scripts/redis/redis_memory_heavy_keys.py --top 100 | ||
| 306 | +``` | ||
| 307 | + | ||
| 308 | +**常用参数**: | ||
| 309 | + | ||
| 310 | +| 参数 | 说明 | 默认 | | ||
| 311 | +|------|------|------| | ||
| 312 | +| `--top` | 显示内存占用最高的 N 个 key | 50 | | ||
| 313 | + | ||
| 314 | +--- | ||
| 315 | + | ||
| 316 | +### 5.4 三个脚本的选用建议 | ||
| 317 | + | ||
| 318 | +| 需求 | 推荐脚本 | | ||
| 319 | +|------|----------| | ||
| 320 | +| 看三类业务缓存(embedding/translation/anchors)的数量、TTL、近期写入、样本 value | `redis_cache_health_check.py` | | ||
| 321 | +| 看全库或某前缀的 key 条数与内存占比 | `redis_cache_prefix_stats.py` | | ||
| 322 | +| 找占用内存最多的大 key、分析内存差异 | `redis_memory_heavy_keys.py` | | ||
| 323 | + | ||
| 324 | +--- | ||
| 325 | + | ||
| 326 | +## 6. 其他 Redis 相关代码(测试与替身) | ||
| 327 | + | ||
| 328 | +除上述运维脚本外,以下代码使用 Redis 或其替身,但不定义新的业务缓存 key 协议: | ||
| 329 | + | ||
| 330 | +- **单元测试**:`tests/test_embedding_pipeline.py` 中的 `_FakeRedis` 用于 mock embedding 缓存;`tests/test_translator_failure_semantics.py` 中的 `_RecordingRedis` 用于验证翻译失败时不写缓存。 | ||
| 331 | + | ||
| 332 | +--- | ||
| 333 | + | ||
| 334 | +## 7. 添加新缓存时的建议 | ||
| 335 | + | ||
| 336 | +新增 Redis 缓存时,建议遵循以下约定: | ||
| 337 | + | ||
| 338 | +- **配置集中**: | ||
| 339 | + - key 前缀、TTL 优先放在 `config/env_config.py`(通用)或 `config/config.yaml -> services.<capability>.cache`; | ||
| 340 | + - 避免在业务代码中硬编码 TTL。 | ||
| 341 | +- **命名规范**: | ||
| 342 | + - 统一使用 `prefix:维度1:维度2:...` 的扁平 key 结构; | ||
| 343 | + - 对长文本/value 使用 `md5`/`sha256` 做哈希,避免过长 key。 | ||
| 344 | +- **文档同步**: | ||
| 345 | + - 新增缓存后,应在本文件中补充一行总览表 + 详细小节; | ||
| 346 | + - 若缓存与外部系统/历史实现兼容(如 Java 侧翻译缓存),需在说明中显式标注。 | ||
| 347 | + |
| @@ -0,0 +1,401 @@ | @@ -0,0 +1,401 @@ | ||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +""" | ||
| 3 | +缓存状态巡检脚本 | ||
| 4 | + | ||
| 5 | +按「缓存类型」维度(embedding / translation / anchors)查看: | ||
| 6 | +- 估算 key 数量 | ||
| 7 | +- TTL 分布(采样) | ||
| 8 | +- 近期活跃 key(按 IDLETIME 近似) | ||
| 9 | +- 若干样本 key 及 value 概览 | ||
| 10 | + | ||
| 11 | +使用示例: | ||
| 12 | + | ||
| 13 | + # 默认:检查已知三类缓存,使用 env_config 中的 Redis 配置 | ||
| 14 | + python scripts/redis/redis_cache_health_check.py | ||
| 15 | + | ||
| 16 | + # 只看某一类缓存 | ||
| 17 | + python scripts/redis/redis_cache_health_check.py --type embedding | ||
| 18 | + python scripts/redis/redis_cache_health_check.py --type translation anchors | ||
| 19 | + | ||
| 20 | + # 自定义前缀(pattern),不限定缓存类型 | ||
| 21 | + python scripts/redis/redis_cache_health_check.py --pattern "mycache:*" | ||
| 22 | + | ||
| 23 | + # 调整采样规模 | ||
| 24 | + python scripts/redis/redis_cache_health_check.py --sample-size 100 --max-scan 50000 | ||
| 25 | +""" | ||
| 26 | + | ||
| 27 | +from __future__ import annotations | ||
| 28 | + | ||
| 29 | +import argparse | ||
| 30 | +import json | ||
| 31 | +import pickle | ||
| 32 | +import sys | ||
| 33 | +from collections import defaultdict | ||
| 34 | +from dataclasses import dataclass | ||
| 35 | +from datetime import datetime | ||
| 36 | +from pathlib import Path | ||
| 37 | +from typing import Dict, Iterable, List, Optional, Tuple | ||
| 38 | + | ||
| 39 | +import redis | ||
| 40 | +import numpy as np | ||
| 41 | + | ||
| 42 | +# 让脚本可以直接使用 config/env_config 与 services_config | ||
| 43 | +PROJECT_ROOT = Path(__file__).parent.parent.parent | ||
| 44 | +sys.path.insert(0, str(PROJECT_ROOT)) | ||
| 45 | + | ||
| 46 | +from config.env_config import REDIS_CONFIG # type: ignore | ||
| 47 | +from config.services_config import get_translation_cache_config # type: ignore | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +@dataclass | ||
| 51 | +class CacheTypeConfig: | ||
| 52 | + name: str | ||
| 53 | + pattern: str | ||
| 54 | + description: str | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def _load_known_cache_types() -> Dict[str, CacheTypeConfig]: | ||
| 58 | + """根据当前配置装配三种已知缓存类型及其前缀 pattern。""" | ||
| 59 | + cache_types: Dict[str, CacheTypeConfig] = {} | ||
| 60 | + | ||
| 61 | + # embedding 缓存:固定 embedding:* 前缀 | ||
| 62 | + cache_types["embedding"] = CacheTypeConfig( | ||
| 63 | + name="embedding", | ||
| 64 | + pattern="embedding:*", | ||
| 65 | + description="文本向量缓存(embeddings/text_encoder.py)", | ||
| 66 | + ) | ||
| 67 | + | ||
| 68 | + # translation 缓存:prefix 来自 services.translation.cache.key_prefix | ||
| 69 | + cache_cfg = get_translation_cache_config() | ||
| 70 | + trans_prefix = cache_cfg.get("key_prefix", "trans:v2") | ||
| 71 | + cache_types["translation"] = CacheTypeConfig( | ||
| 72 | + name="translation", | ||
| 73 | + pattern=f"{trans_prefix}:*", | ||
| 74 | + description="翻译结果缓存(query/qwen_mt_translate.Translator)", | ||
| 75 | + ) | ||
| 76 | + | ||
| 77 | + # anchors 缓存:prefix 来自 REDIS_CONFIG['anchor_cache_prefix'](若存在),否则 product_anchors | ||
| 78 | + anchor_prefix = REDIS_CONFIG.get("anchor_cache_prefix", "product_anchors") | ||
| 79 | + cache_types["anchors"] = CacheTypeConfig( | ||
| 80 | + name="anchors", | ||
| 81 | + pattern=f"{anchor_prefix}:*", | ||
| 82 | + description="商品内容理解缓存(indexer/product_enrich.py,anchors/语义属性/tags)", | ||
| 83 | + ) | ||
| 84 | + | ||
| 85 | + return cache_types | ||
| 86 | + | ||
| 87 | + | ||
| 88 | +def get_redis_client(db: int = 0) -> redis.Redis: | ||
| 89 | + return redis.Redis( | ||
| 90 | + host=REDIS_CONFIG.get("host", "localhost"), | ||
| 91 | + port=REDIS_CONFIG.get("port", 6479), | ||
| 92 | + password=REDIS_CONFIG.get("password"), | ||
| 93 | + db=db, | ||
| 94 | + decode_responses=False, # 原始 bytes,方便区分 pickle / str | ||
| 95 | + socket_timeout=10, | ||
| 96 | + socket_connect_timeout=10, | ||
| 97 | + ) | ||
| 98 | + | ||
| 99 | + | ||
| 100 | +def scan_keys( | ||
| 101 | + client: redis.Redis, pattern: str, max_scan: int, scan_count: int = 1000 | ||
| 102 | +) -> List[bytes]: | ||
| 103 | + """使用 SCAN 扫描匹配 pattern 的 key,最多扫描 max_scan 个结果。""" | ||
| 104 | + keys: List[bytes] = [] | ||
| 105 | + cursor: int = 0 | ||
| 106 | + scanned = 0 | ||
| 107 | + while True: | ||
| 108 | + cursor, batch = client.scan(cursor=cursor, match=pattern, count=scan_count) | ||
| 109 | + for k in batch: | ||
| 110 | + keys.append(k) | ||
| 111 | + scanned += 1 | ||
| 112 | + if scanned >= max_scan: | ||
| 113 | + return keys | ||
| 114 | + if cursor == 0: | ||
| 115 | + break | ||
| 116 | + return keys | ||
| 117 | + | ||
| 118 | + | ||
| 119 | +def ttl_bucket(ttl: int) -> str: | ||
| 120 | + """将 TTL(秒)归类到简短区间标签中。""" | ||
| 121 | + if ttl < 0: | ||
| 122 | + # -1: 永不过期;-2: 不存在 | ||
| 123 | + return "no-expire-or-expired" | ||
| 124 | + if ttl <= 3600: | ||
| 125 | + return "0-1h" | ||
| 126 | + if ttl <= 86400: | ||
| 127 | + return "1h-1d" | ||
| 128 | + if ttl <= 30 * 86400: | ||
| 129 | + return "1d-30d" | ||
| 130 | + return ">30d" | ||
| 131 | + | ||
| 132 | + | ||
| 133 | +def format_seconds(sec: int) -> str: | ||
| 134 | + if sec < 0: | ||
| 135 | + return str(sec) | ||
| 136 | + if sec < 60: | ||
| 137 | + return f"{sec}s" | ||
| 138 | + if sec < 3600: | ||
| 139 | + return f"{sec // 60}m{sec % 60}s" | ||
| 140 | + if sec < 86400: | ||
| 141 | + h = sec // 3600 | ||
| 142 | + m = (sec % 3600) // 60 | ||
| 143 | + return f"{h}h{m}m" | ||
| 144 | + d = sec // 86400 | ||
| 145 | + h = (sec % 86400) // 3600 | ||
| 146 | + return f"{d}d{h}h" | ||
| 147 | + | ||
| 148 | + | ||
| 149 | +def decode_value_preview( | ||
| 150 | + cache_type: str, key: bytes, raw_value: Optional[bytes] | ||
| 151 | +) -> str: | ||
| 152 | + """根据缓存类型生成简短的 value 概览字符串。""" | ||
| 153 | + if raw_value is None: | ||
| 154 | + return "<nil>" | ||
| 155 | + | ||
| 156 | + # embedding: pickle 序列化的 numpy.ndarray | ||
| 157 | + if cache_type == "embedding": | ||
| 158 | + try: | ||
| 159 | + arr = pickle.loads(raw_value) | ||
| 160 | + if isinstance(arr, np.ndarray): | ||
| 161 | + return f"ndarray shape={arr.shape} dtype={arr.dtype}" | ||
| 162 | + return f"pickle object type={type(arr).__name__}" | ||
| 163 | + except Exception: | ||
| 164 | + return f"<binary {len(raw_value)} bytes>" | ||
| 165 | + | ||
| 166 | + # anchors: JSON dict | ||
| 167 | + if cache_type == "anchors": | ||
| 168 | + try: | ||
| 169 | + text = raw_value.decode("utf-8", errors="replace") | ||
| 170 | + obj = json.loads(text) | ||
| 171 | + if isinstance(obj, dict): | ||
| 172 | + brief = { | ||
| 173 | + k: obj.get(k) | ||
| 174 | + for k in ["id", "lang", "title_input", "title", "category_path", "anchor_text"] | ||
| 175 | + if k in obj | ||
| 176 | + } | ||
| 177 | + return "json " + json.dumps(brief, ensure_ascii=False)[:200] | ||
| 178 | + # 其他情况简单截断 | ||
| 179 | + return "json " + text[:200] | ||
| 180 | + except Exception: | ||
| 181 | + return raw_value.decode("utf-8", errors="replace")[:200] | ||
| 182 | + | ||
| 183 | + # translation: 纯字符串 | ||
| 184 | + if cache_type == "translation": | ||
| 185 | + try: | ||
| 186 | + text = raw_value.decode("utf-8", errors="replace") | ||
| 187 | + return text[:200] | ||
| 188 | + except Exception: | ||
| 189 | + return f"<binary {len(raw_value)} bytes>" | ||
| 190 | + | ||
| 191 | + # 兜底:尝试解码为 UTF-8 | ||
| 192 | + try: | ||
| 193 | + text = raw_value.decode("utf-8", errors="replace") | ||
| 194 | + return text[:200] | ||
| 195 | + except Exception: | ||
| 196 | + return f"<binary {len(raw_value)} bytes>" | ||
| 197 | + | ||
| 198 | + | ||
| 199 | +def analyze_cache_type( | ||
| 200 | + client: redis.Redis, | ||
| 201 | + cache_type: str, | ||
| 202 | + cfg: CacheTypeConfig, | ||
| 203 | + sample_size: int, | ||
| 204 | + max_scan: int, | ||
| 205 | +) -> None: | ||
| 206 | + """对单个缓存类型做统计与样本展示。""" | ||
| 207 | + print("=" * 80) | ||
| 208 | + print(f"Cache type: {cache_type} pattern={cfg.pattern}") | ||
| 209 | + print(f"Description: {cfg.description}") | ||
| 210 | + print("=" * 80) | ||
| 211 | + | ||
| 212 | + keys = scan_keys(client, cfg.pattern, max_scan=max_scan) | ||
| 213 | + total_scanned = len(keys) | ||
| 214 | + print(f"Scanned up to {max_scan} keys, matched {total_scanned} keys.") | ||
| 215 | + if total_scanned == 0: | ||
| 216 | + print("No keys found for this cache type.\n") | ||
| 217 | + return | ||
| 218 | + | ||
| 219 | + # 采样 | ||
| 220 | + if total_scanned <= sample_size: | ||
| 221 | + sampled_keys = keys | ||
| 222 | + else: | ||
| 223 | + # 简单取前 sample_size 个,scan 本身已是渐进遍历,足够近似随机 | ||
| 224 | + sampled_keys = keys[:sample_size] | ||
| 225 | + | ||
| 226 | + ttl_hist: Dict[str, int] = defaultdict(int) | ||
| 227 | + recent_keys: List[Tuple[bytes, int, int]] = [] # (key, ttl, idletime) | ||
| 228 | + samples: List[Tuple[bytes, int, int, str]] = [] # (key, ttl, idletime, preview) | ||
| 229 | + | ||
| 230 | + for k in sampled_keys: | ||
| 231 | + try: | ||
| 232 | + ttl = client.ttl(k) | ||
| 233 | + except Exception: | ||
| 234 | + ttl = -3 # 表示 TTL 查询失败 | ||
| 235 | + | ||
| 236 | + # TTL 分布 | ||
| 237 | + ttl_hist[ttl_bucket(ttl)] += 1 | ||
| 238 | + | ||
| 239 | + # 近期活跃判断(idletime 越小越“新”) | ||
| 240 | + idletime = -1 | ||
| 241 | + try: | ||
| 242 | + # OBJECT IDLETIME 返回秒数(整数) | ||
| 243 | + idletime = client.object("idletime", k) # type: ignore[arg-type] | ||
| 244 | + except Exception: | ||
| 245 | + pass | ||
| 246 | + | ||
| 247 | + # 记录近期活跃样本 | ||
| 248 | + if idletime >= 0 and idletime <= 600: | ||
| 249 | + recent_keys.append((k, ttl, idletime)) | ||
| 250 | + | ||
| 251 | + # 收集样本 value 预览(控制数量) | ||
| 252 | + if len(samples) < 5: | ||
| 253 | + raw_val = None | ||
| 254 | + try: | ||
| 255 | + raw_val = client.get(k) | ||
| 256 | + except Exception: | ||
| 257 | + pass | ||
| 258 | + preview = decode_value_preview(cache_type, k, raw_val) | ||
| 259 | + samples.append((k, ttl, idletime, preview)) | ||
| 260 | + | ||
| 261 | + # TTL 分布输出 | ||
| 262 | + print("\nTTL distribution (sampled):") | ||
| 263 | + total_sampled = len(sampled_keys) | ||
| 264 | + for bucket in ["no-expire-or-expired", "0-1h", "1h-1d", "1d-30d", ">30d"]: | ||
| 265 | + cnt = ttl_hist.get(bucket, 0) | ||
| 266 | + pct = (cnt / total_sampled * 100.0) if total_sampled else 0.0 | ||
| 267 | + print(f" {bucket:<18}: {cnt:>4} ({pct:>5.1f}%)") | ||
| 268 | + | ||
| 269 | + # 近期活跃 key | ||
| 270 | + recent_keys = sorted(recent_keys, key=lambda x: x[2])[:5] | ||
| 271 | + print("\nRecent active keys (idletime <= 600s, from sampled set):") | ||
| 272 | + if not recent_keys: | ||
| 273 | + print(" (none in sampled set)") | ||
| 274 | + else: | ||
| 275 | + for k, ttl, idle in recent_keys: | ||
| 276 | + try: | ||
| 277 | + k_str = k.decode("utf-8", errors="replace") | ||
| 278 | + except Exception: | ||
| 279 | + k_str = repr(k) | ||
| 280 | + if len(k_str) > 80: | ||
| 281 | + k_str = k_str[:77] + "..." | ||
| 282 | + print( | ||
| 283 | + f" key={k_str} ttl={ttl} ({format_seconds(ttl)}) " | ||
| 284 | + f"idletime={idle} ({format_seconds(idle)})" | ||
| 285 | + ) | ||
| 286 | + | ||
| 287 | + # 样本 value 概览 | ||
| 288 | + print("\nSample keys & value preview:") | ||
| 289 | + if not samples: | ||
| 290 | + print(" (no samples)") | ||
| 291 | + else: | ||
| 292 | + for k, ttl, idle, preview in samples: | ||
| 293 | + try: | ||
| 294 | + k_str = k.decode("utf-8", errors="replace") | ||
| 295 | + except Exception: | ||
| 296 | + k_str = repr(k) | ||
| 297 | + if len(k_str) > 80: | ||
| 298 | + k_str = k_str[:77] + "..." | ||
| 299 | + print(f" key={k_str}") | ||
| 300 | + print(f" ttl={ttl} ({format_seconds(ttl)}) idletime={idle} ({format_seconds(idle)})") | ||
| 301 | + print(f" value: {preview}") | ||
| 302 | + | ||
| 303 | + print() # 结尾空行 | ||
| 304 | + | ||
| 305 | + | ||
| 306 | +def main() -> None: | ||
| 307 | + parser = argparse.ArgumentParser(description="Redis 缓存状态巡检(按缓存类型)") | ||
| 308 | + parser.add_argument( | ||
| 309 | + "--type", | ||
| 310 | + dest="types", | ||
| 311 | + nargs="+", | ||
| 312 | + choices=["embedding", "translation", "anchors"], | ||
| 313 | + help="指定要检查的缓存类型(默认:三种全部)", | ||
| 314 | + ) | ||
| 315 | + parser.add_argument( | ||
| 316 | + "--pattern", | ||
| 317 | + type=str, | ||
| 318 | + help="自定义 key pattern(如 'mycache:*')。设置后将忽略 --type,仅按 pattern 进行一次检查。", | ||
| 319 | + ) | ||
| 320 | + parser.add_argument( | ||
| 321 | + "--db", | ||
| 322 | + type=int, | ||
| 323 | + default=0, | ||
| 324 | + help="Redis 数据库编号(默认 0)", | ||
| 325 | + ) | ||
| 326 | + parser.add_argument( | ||
| 327 | + "--sample-size", | ||
| 328 | + type=int, | ||
| 329 | + default=50, | ||
| 330 | + help="每种缓存类型采样的 key 数量(默认 50)", | ||
| 331 | + ) | ||
| 332 | + parser.add_argument( | ||
| 333 | + "--max-scan", | ||
| 334 | + type=int, | ||
| 335 | + default=20000, | ||
| 336 | + help="每种缓存类型最多 SCAN 的 key 数量上限(默认 20000)", | ||
| 337 | + ) | ||
| 338 | + | ||
| 339 | + args = parser.parse_args() | ||
| 340 | + | ||
| 341 | + print("Redis 缓存状态巡检") | ||
| 342 | + print("=" * 80) | ||
| 343 | + print(f"Checked at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") | ||
| 344 | + print(f"Redis host={REDIS_CONFIG.get('host', 'localhost')} port={REDIS_CONFIG.get('port', 6479)} db={args.db}") | ||
| 345 | + print() | ||
| 346 | + | ||
| 347 | + try: | ||
| 348 | + client = get_redis_client(db=args.db) | ||
| 349 | + client.ping() | ||
| 350 | + print("✅ Redis 连接成功\n") | ||
| 351 | + except Exception as exc: | ||
| 352 | + print(f"❌ Redis 连接失败: {exc}") | ||
| 353 | + print(f" Host: {REDIS_CONFIG.get('host', 'localhost')}") | ||
| 354 | + print(f" Port: {REDIS_CONFIG.get('port', 6479)}") | ||
| 355 | + print(f" Password: {'已配置' if REDIS_CONFIG.get('password') else '未配置'}") | ||
| 356 | + return | ||
| 357 | + | ||
| 358 | + # 如果指定了自定义 pattern,则只做一次“匿名类型”的巡检 | ||
| 359 | + if args.pattern: | ||
| 360 | + anon_cfg = CacheTypeConfig( | ||
| 361 | + name=f"pattern:{args.pattern}", | ||
| 362 | + pattern=args.pattern, | ||
| 363 | + description="自定义 pattern 检查", | ||
| 364 | + ) | ||
| 365 | + analyze_cache_type( | ||
| 366 | + client=client, | ||
| 367 | + cache_type="custom", | ||
| 368 | + cfg=anon_cfg, | ||
| 369 | + sample_size=args.sample_size, | ||
| 370 | + max_scan=args.max_scan, | ||
| 371 | + ) | ||
| 372 | + print("巡检完成。") | ||
| 373 | + return | ||
| 374 | + | ||
| 375 | + # 否则根据已知缓存类型巡检 | ||
| 376 | + known_types = _load_known_cache_types() | ||
| 377 | + types_to_check: Iterable[str] | ||
| 378 | + if args.types: | ||
| 379 | + types_to_check = args.types | ||
| 380 | + else: | ||
| 381 | + types_to_check = known_types.keys() | ||
| 382 | + | ||
| 383 | + for t in types_to_check: | ||
| 384 | + cfg = known_types.get(t) | ||
| 385 | + if not cfg: | ||
| 386 | + print(f"⚠️ 未知缓存类型: {t},跳过") | ||
| 387 | + continue | ||
| 388 | + analyze_cache_type( | ||
| 389 | + client=client, | ||
| 390 | + cache_type=t, | ||
| 391 | + cfg=cfg, | ||
| 392 | + sample_size=args.sample_size, | ||
| 393 | + max_scan=args.max_scan, | ||
| 394 | + ) | ||
| 395 | + | ||
| 396 | + print("巡检完成。") | ||
| 397 | + | ||
| 398 | + | ||
| 399 | +if __name__ == "__main__": | ||
| 400 | + main() | ||
| 401 | + |
scripts/redis/check_cache_stats.py renamed to scripts/redis/redis_cache_prefix_stats.py
| @@ -7,19 +7,19 @@ | @@ -7,19 +7,19 @@ | ||
| 7 | 使用方法: | 7 | 使用方法: |
| 8 | 8 | ||
| 9 | 直接使用(默认数据库 0): | 9 | 直接使用(默认数据库 0): |
| 10 | -python scripts/redis/check_cache_stats.py | 10 | +python scripts/redis/redis_cache_prefix_stats.py |
| 11 | 11 | ||
| 12 | 统计所有数据库: | 12 | 统计所有数据库: |
| 13 | -python scripts/redis/check_cache_stats.py --all-db | 13 | +python scripts/redis/redis_cache_prefix_stats.py --all-db |
| 14 | 14 | ||
| 15 | 统计指定数据库: | 15 | 统计指定数据库: |
| 16 | -python scripts/redis/check_cache_stats.py --db 1 | 16 | +python scripts/redis/redis_cache_prefix_stats.py --db 1 |
| 17 | 17 | ||
| 18 | 只统计以下三种前缀: | 18 | 只统计以下三种前缀: |
| 19 | -python scripts/redis/check_cache_stats.py --prefix trans embedding product | 19 | +python scripts/redis/redis_cache_prefix_stats.py --prefix trans embedding product |
| 20 | 20 | ||
| 21 | 统计所有数据库的指定前缀: | 21 | 统计所有数据库的指定前缀: |
| 22 | -python scripts/redis/check_cache_stats.py --all-db --prefix trans embedding | 22 | +python scripts/redis/redis_cache_prefix_stats.py --all-db --prefix trans embedding |
| 23 | 23 | ||
| 24 | 24 | ||
| 25 | 25 | ||
| @@ -487,7 +487,7 @@ def analyze_all_databases(args): | @@ -487,7 +487,7 @@ def analyze_all_databases(args): | ||
| 487 | print(f"总 key 数量: {total_keys_all_db:,}") | 487 | print(f"总 key 数量: {total_keys_all_db:,}") |
| 488 | print(f"\n提示: 要查看详细的内存统计,请分别运行每个数据库:") | 488 | print(f"\n提示: 要查看详细的内存统计,请分别运行每个数据库:") |
| 489 | for db_num in databases: | 489 | for db_num in databases: |
| 490 | - print(f" python scripts/redis/check_cache_stats.py --db {db_num}") | 490 | + print(f" python scripts/redis/redis_cache_prefix_stats.py --db {db_num}") |
| 491 | 491 | ||
| 492 | def main(): | 492 | def main(): |
| 493 | """主函数""" | 493 | """主函数""" |
scripts/redis/find_memory_usage.py renamed to scripts/redis/redis_memory_heavy_keys.py