Răsfoiți Sursa

docs: add comprehensive Redis key naming documentation

- Add Redis Key Architecture section to CLAUDE.md
- Document fixed vs rolling window naming conventions
- Explain why fixed mode needs suffix and rolling mode doesn't
- Add detailed examples for all time periods (daily/5h/weekly/monthly)
- Include debugging commands and troubleshooting guide
- Add inline documentation in rate-limit/service.ts
- Benefits: easier debugging, better maintainability, clearer design rationale
- Addresses code review feedback from PR #161
ding113 3 luni în urmă
părinte
comite
bf8be159be
2 a modificat fișierele cu 226 adăugiri și 0 ștergeri
  1. 159 0
      CLAUDE.md
  2. 67 0
      src/lib/rate-limit/service.ts

+ 159 - 0
CLAUDE.md

@@ -206,6 +206,165 @@ src/
 4. **Redis Lua 脚本** - 原子性检查和递增(解决竞态条件)
 5. **Fail Open 策略** - Redis 不可用时降级,不影响服务
 
+### Redis Key 架构
+
+#### 日限额 Redis Key 命名规范
+
+本系统使用不同的 Redis 数据结构来实现固定窗口和滚动窗口的日限额追踪。理解这些命名规范对于调试、监控和故障排查至关重要。
+
+**核心设计原则**:
+- 固定窗口使用 STRING 类型,支持自定义重置时间
+- 滚动窗口使用 ZSET 类型,提供精确的时间窗口计算
+
+#### Key 命名模式
+
+**1. 固定时间窗口 (Fixed Mode)**
+
+格式:`{type}:{id}:cost_daily_{suffix}`
+
+```
+示例:
+  key:123:cost_daily_1800      # Key ID 123,每天 18:00 重置
+  key:456:cost_daily_0000      # Key ID 456,每天 00:00 重置
+  provider:789:cost_daily_0930  # Provider ID 789,每天 09:30 重置
+```
+
+**特性**:
+- Redis 类型:STRING
+- 操作命令:INCRBYFLOAT(累加)、GET(查询)
+- Suffix 规则:重置时间去掉冒号(HH:mm → HHmm)
+- TTL 策略:动态计算到下一个重置时间的秒数
+- 用例:支持不同用户/供应商的自定义重置时间
+
+**为什么需要 Suffix?**
+
+不同用户可能配置不同的重置时间:
+- 用户 A 配置 18:00 重置 → `key:1:cost_daily_1800`
+- 用户 B 配置 00:00 重置 → `key:2:cost_daily_0000`
+
+如果省略 suffix,两个用户会使用相同的 key 模式,导致 TTL 冲突和数据混乱。
+
+**2. 滚动时间窗口 (Rolling Mode)**
+
+格式:`{type}:{id}:cost_daily_rolling`
+
+```
+示例:
+  key:123:cost_daily_rolling      # Key ID 123,滚动 24 小时窗口
+  provider:456:cost_daily_rolling  # Provider ID 456,滚动 24 小时窗口
+```
+
+**特性**:
+- Redis 类型:ZSET(Sorted Set)
+- 操作命令:Lua 脚本(ZADD + ZREMRANGEBYSCORE + ZRANGE)
+- Suffix 规则:固定使用 `rolling`,无时间后缀
+- TTL 策略:固定 24 小时(86400 秒)
+- 用例:真正的滚动窗口,统计"过去 24 小时"的消费
+
+**为什么不需要 Suffix?**
+
+滚动窗口没有固定的重置时间点:
+- 每次查询都是"当前时间往前推 24 小时"
+- 所有用户使用相同的窗口计算逻辑
+- TTL 固定为 24 小时,无需区分重置时间
+
+**3. 其他时间周期**
+
+```
+5 小时滚动窗口(ZSET):
+  key:123:cost_5h_rolling
+  provider:456:cost_5h_rolling
+
+周限额(STRING,每周一 00:00 重置):
+  key:123:cost_weekly
+  provider:456:cost_weekly
+
+月限额(STRING,每月 1 号 00:00 重置):
+  key:123:cost_monthly
+  provider:456:cost_monthly
+```
+
+#### 数据结构对比
+
+| 模式     | Redis 类型 | 命名示例                    | TTL 策略          | 时间精度 | 操作复杂度 |
+|----------|------------|----------------------------|-------------------|----------|------------|
+| 固定窗口 | STRING     | `cost_daily_1800`          | 动态(到重置时间) | 分钟级   | 简单       |
+| 滚动窗口 | ZSET       | `cost_daily_rolling`       | 固定(24h)       | 毫秒级   | 中等       |
+| 5h 滚动  | ZSET       | `cost_5h_rolling`          | 固定(5h)        | 毫秒级   | 中等       |
+| 周/月    | STRING     | `cost_weekly`/`cost_monthly` | 动态(到下周期)  | 分钟级   | 简单       |
+
+#### 实现细节
+
+**固定窗口操作流程**:
+
+```typescript
+// 1. 写入消费数据(累加)
+const key = `key:${keyId}:cost_daily_1800`;
+const ttl = calculateTTLToNextReset("18:00"); // 计算到下一个 18:00 的秒数
+await redis.incrbyfloat(key, cost);
+await redis.expire(key, ttl);
+
+// 2. 查询当前消费
+const current = await redis.get(key);
+```
+
+**滚动窗口操作流程**:
+
+```typescript
+// 1. 写入消费数据(使用 Lua 脚本)
+const key = `key:${keyId}:cost_daily_rolling`;
+const now = Date.now();
+const window = 24 * 60 * 60 * 1000; // 24 小时
+await redis.eval(TRACK_COST_DAILY_ROLLING_WINDOW, 1, key, cost, now, window);
+
+// 2. 查询当前消费(使用 Lua 脚本)
+const current = await redis.eval(GET_COST_DAILY_ROLLING_WINDOW, 1, key, now, window);
+```
+
+**Lua 脚本优势**:
+- 原子性:查询、清理过期数据、累加在一个操作内完成
+- 精确性:基于毫秒级时间戳,避免边界问题
+- 性能:减少网络往返次数
+
+#### 调试和监控
+
+**检查 Redis Key**:
+
+```bash
+# 查看所有日限额 key(固定窗口)
+redis-cli KEYS "*:cost_daily_*"
+
+# 查看滚动窗口 key
+redis-cli KEYS "*:cost_daily_rolling"
+
+# 查看具体 key 的值
+redis-cli GET "key:123:cost_daily_1800"
+
+# 查看 ZSET 的详细数据
+redis-cli ZRANGE "key:123:cost_daily_rolling" 0 -1 WITHSCORES
+```
+
+**常见问题排查**:
+
+1. **Key 不存在**:
+   - 原因:Redis 重启导致数据丢失
+   - 解决:系统会自动从数据库恢复(Cache Warming)
+
+2. **TTL 异常**:
+   - 检查:`redis-cli TTL "key:123:cost_daily_1800"`
+   - 预期:固定窗口为动态值,滚动窗口为 86400
+
+3. **消费统计不准确**:
+   - 固定窗口:检查重置时间配置是否正确
+   - 滚动窗口:检查 ZSET 中的时间戳范围
+
+#### 相关文件
+
+- **核心实现**:`src/lib/rate-limit/service.ts`(包含详细注释)
+- **Lua 脚本**:`src/lib/redis/lua-scripts.ts`
+- **时间工具**:`src/lib/rate-limit/time-utils.ts`
+- **数据库层**:`src/repository/statistics.ts`
+
 ### Session 管理
 
 Session 追踪和缓存 (`src/lib/session-manager.ts`):

+ 67 - 0
src/lib/rate-limit/service.ts

@@ -1,3 +1,70 @@
+/**
+ * ============================================================================
+ * Rate Limit Service - Redis Key Naming Conventions
+ * ============================================================================
+ *
+ * This service implements cost tracking using different Redis data structures
+ * based on the time window mode (fixed vs rolling). Understanding the key
+ * naming patterns is crucial for debugging and maintenance.
+ *
+ * ## Key Naming Patterns
+ *
+ * ### 1. Fixed Time Window Keys (STRING type)
+ *    Format: `{type}:{id}:cost_daily_{suffix}`
+ *    Example: `key:123:cost_daily_1800` (resets at 18:00)
+ *             `provider:456:cost_daily_0000` (resets at 00:00)
+ *
+ *    - Uses Redis STRING type with INCRBYFLOAT
+ *    - Suffix is the reset time without colon (HH:mm -> HHmm)
+ *    - TTL: Dynamic, calculated to the next reset time
+ *    - Use case: Custom daily reset times (e.g., 18:00, 09:30)
+ *
+ * ### 2. Rolling Window Keys (ZSET type)
+ *    Format: `{type}:{id}:cost_daily_rolling`
+ *    Example: `key:123:cost_daily_rolling`
+ *             `provider:456:cost_daily_rolling`
+ *
+ *    - Uses Redis ZSET type with Lua scripts
+ *    - No time suffix - always "rolling"
+ *    - TTL: Fixed 24 hours (86400 seconds)
+ *    - Use case: True rolling 24-hour window (past 24 hours from now)
+ *
+ * ### 3. Other Period Keys (STRING type)
+ *    Format: `{type}:{id}:cost_{period}`
+ *    Example: `key:123:cost_weekly` (Monday 00:00 reset)
+ *             `key:123:cost_monthly` (1st day 00:00 reset)
+ *             `key:123:cost_5h_rolling` (5-hour rolling, ZSET)
+ *
+ * ## Why Different Patterns?
+ *
+ * ### Fixed Mode (`cost_daily_{suffix}`)
+ * - **Problem**: Multiple users may have different daily reset times
+ * - **Solution**: Include reset time in key name to avoid conflicts
+ * - **Example**: User A resets at 18:00, User B resets at 00:00
+ *   - Key A: `key:1:cost_daily_1800` (TTL to next 18:00)
+ *   - Key B: `key:2:cost_daily_0000` (TTL to next 00:00)
+ *
+ * ### Rolling Mode (`cost_daily_rolling`)
+ * - **Problem**: Rolling windows don't have a fixed reset time
+ * - **Solution**: Use generic "rolling" suffix, no time needed
+ * - **Advantage**: Simpler key naming, consistent TTL (24h)
+ * - **Trade-off**: Requires ZSET + Lua script (more complex but precise)
+ *
+ * ## Data Structure Comparison
+ *
+ * | Mode    | Type   | Operations      | TTL Strategy        | Precision |
+ * |---------|--------|-----------------|---------------------|-----------|
+ * | Fixed   | STRING | INCRBYFLOAT     | Dynamic (to reset)  | Minute    |
+ * | Rolling | ZSET   | Lua + ZADD      | Fixed (24h)         | Millisec  |
+ *
+ * ## Related Files
+ * - Lua Scripts: src/lib/redis/lua-scripts.ts
+ * - Time Utils: src/lib/rate-limit/time-utils.ts
+ * - Documentation: CLAUDE.md (Redis Key Architecture section)
+ *
+ * ============================================================================
+ */
+
 import { getRedisClient } from "@/lib/redis";
 import { logger } from "@/lib/logger";
 import { SessionTracker } from "@/lib/session-tracker";