---
change: storage-cache-refactor
design-doc: docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md
base-ref: 8b5e43f4915cef786208a8132460f87b8b191765
archived-with: 2026-07-04-storage-cache-refactor
---

# Storage Cache Refactor - 实施计划

> 基于 [Design Doc](docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md) 拆分的可执行任务。
> 目标：引入 Redis（缓存）+ MySQL（持久化）+ SQLite（兜底）三级存储架构，改造现有 cache.py 内部封装 StorageManager，API 层零改动。

---

## 任务总览

| # | 任务 | 预估 | 依赖 |
|---|------|------|------|
| T1 | 添加 redis/pymysql 依赖 | 5min | - |
| T2 | config.py 新增 Redis/MySQL 配置项 | 10min | - |
| T3 | docker-compose.yml 新增 Redis/MySQL 服务 | 10min | T2 |
| T4 | 创建 app/mysql_database.py | 20min | T2 |
| T5 | 创建 app/redis_client.py | 20min | T2 |
| T6 | lifespan 中初始化 Redis/MySQL 连接 | 15min | T4, T5 |
| T7 | MySQL 表结构初始化（复用 ORM 模型） | 15min | T4, T6 |
| T8 | 创建 app/storage_manager.py 骨架 + 降级检测 | 30min | T4, T5, T6 |
| T9 | StorageManager 读取逻辑（Redis → MySQL → SQLite） | 30min | T8 |
| T10 | StorageManager 写入逻辑 + 双写一致性 | 30min | T8 |
| T11 | 创建数据迁移脚本 app/migration.py | 25min | T4, T7 |
| T12 | 启动时自动触发迁移 | 10min | T11 |
| T13 | 改造 cache.py 内部封装 StorageManager | 30min | T9, T10 |
| T14 | 验证：Redis 缓存命中/未命中场景 | 15min | T13 |
| T15 | 验证：降级到 MySQL / SQLite 场景 | 15min | T13 |
| T16 | 验证：双写一致性 + 数据迁移完整性 | 15min | T13 |

---

## 详细任务

### T1: 添加 redis/pymysql 依赖

**文件**: `requirements.txt`

**操作**:
- 在 `requirements.txt` 末尾追加：
  ```
  redis>=5.0.0
  pymysql>=1.1.0
  cryptography>=42.0.0
  ```
- `cryptography` 是 PyMySQL 启用加密连接所需的可选依赖

**验证**: `pip install -r requirements.txt` 成功安装无报错

---

### T2: config.py 新增 Redis/MySQL 配置项

**文件**: `app/config.py`

**操作**:
- 在现有配置末尾新增以下配置块（参考 Design Doc §7.1）：
  ```python
  # Redis 配置
  REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
  REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
  REDIS_DB = int(os.getenv("REDIS_DB", "0"))
  REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
  REDIS_TTL_SECONDS = int(os.getenv("REDIS_TTL_SECONDS", "2592000"))  # 30 天

  # MySQL 配置
  MYSQL_HOST = os.getenv("MYSQL_HOST", "localhost")
  MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
  MYSQL_USER = os.getenv("MYSQL_USER", "root")
  MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
  MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "buffer_platform")
  ```

**验证**: `from app.config import REDIS_HOST, MYSQL_HOST` 无报错，默认值正确

---

### T3: docker-compose.yml 新增 Redis/MySQL 服务

**文件**: `docker-compose.yml`

**操作**:
- 在 `services` 下新增 `redis` 和 `mysql` 服务（参考 Design Doc §7.2）：
  ```yaml
  redis:
    image: redis:7-alpine
    container_name: futures-redis
    restart: unless-stopped
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    networks:
      - futures-network

  mysql:
    image: mysql:8.0
    container_name: futures-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-buffer123}
      MYSQL_DATABASE: ${MYSQL_DATABASE:-buffer_platform}
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    networks:
      - futures-network
  ```
- 在顶层新增 `volumes` 声明：
  ```yaml
  volumes:
    redis-data:
    mysql-data:
  ```
- 在 `buffer-platform` 的 `environment` 中新增 Redis/MySQL 环境变量传递

**验证**: `docker-compose config` 解析无语法错误

---

### T4: 创建 app/mysql_database.py

**文件**: `app/mysql_database.py`（新建）

**操作**:
- 创建 MySQL 连接引擎和 SessionLocal，参考现有 `app/database.py` 的结构：
  ```python
  from sqlalchemy import create_engine, text
  from sqlalchemy.orm import sessionmaker
  from app.config import MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE

  mysql_engine = None
  MySQLSessionLocal = None

  def init_mysql():
      """初始化 MySQL 连接引擎，失败时返回 None"""
      global mysql_engine, MySQLSessionLocal
      try:
          url = f"mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}?charset=utf8mb4"
          mysql_engine = create_engine(url, pool_pre_ping=True, pool_recycle=3600)
          with mysql_engine.connect() as conn:
              conn.execute(text("SELECT 1"))
          MySQLSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=mysql_engine)
          return mysql_engine
      except Exception as e:
          logger.warning(f"MySQL 初始化失败: {e}")
          mysql_engine = None
          MySQLSessionLocal = None
          return None

  def get_mysql_session():
      """获取 MySQL 会话"""
      if MySQLSessionLocal is None:
          raise RuntimeError("MySQL 未初始化")
      return MySQLSessionLocal()
  ```

**验证**: 手动调用 `init_mysql()`，在无 MySQL 环境下返回 `None` 不抛异常

---

### T5: 创建 app/redis_client.py

**文件**: `app/redis_client.py`（新建）

**操作**:
- 封装 Redis 连接池和客户端：
  ```python
  import redis
  from app.config import REDIS_HOST, REDIS_PORT, REDIS_DB, REDIS_PASSWORD

  redis_client = None

  def init_redis():
      """初始化 Redis 客户端，失败时返回 None"""
      global redis_client
      try:
          redis_client = redis.Redis(
              host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB,
              password=REDIS_PASSWORD or None,
              decode_responses=True,
              socket_connect_timeout=5,
          )
          redis_client.ping()
          return redis_client
      except Exception as e:
          logger.warning(f"Redis 初始化失败: {e}")
          redis_client = None
          return None

  def get_redis():
      """获取 Redis 客户端实例"""
      return redis_client
  ```

**验证**: 手动调用 `init_redis()`，在无 Redis 环境下返回 `None` 不抛异常

---

### T6: lifespan 中初始化 Redis/MySQL 连接

**文件**: `app/main.py`

**操作**:
- 在 `lifespan` 函数中，`Base.metadata.create_all` 之后新增：
  ```python
  # 初始化 Redis 和 MySQL
  from app.redis_client import init_redis
  from app.mysql_database import init_mysql

  redis_ok = init_redis() is not None
  mysql_ok = init_mysql() is not None

  if redis_ok and mysql_ok:
      logger.info("存储模式: Redis + MySQL")
  elif mysql_ok:
      logger.warning("存储模式: MySQL (Redis 不可用)")
  else:
      logger.error("存储模式: SQLite (Redis 和 MySQL 均不可用)")
  ```

**验证**: 应用启动日志中输出存储模式信息，无 Redis/MySQL 时降级提示正常

---

### T7: MySQL 表结构初始化（复用 ORM 模型）

**文件**: `app/main.py`（修改 lifespan）

**操作**:
- 在 T6 初始化代码之后，如果 MySQL 可用，使用现有 ORM 模型创建表：
  ```python
  if mysql_ok:
      from app.mysql_database import mysql_engine
      from app.models import Base as MarketBase
      # 在 MySQL 中创建行情相关表
      MarketBase.metadata.create_all(bind=mysql_engine)
      logger.info("MySQL 表结构初始化完成")
  ```
- 注意：只需创建 `market_data` 和 `symbol_timestamps` 两张表，`scheduled_tasks` 等低频表仍留在 SQLite

**验证**: MySQL 中可查到 `market_data` 和 `symbol_timestamps` 表结构

---

### T8: 创建 app/storage_manager.py 骨架 + 降级检测

**文件**: `app/storage_manager.py`（新建）

**操作**:
- 实现 `StorageManager` 类骨架，包含：
  - `__init__`: 持有 redis_client / mysql_engine 引用，初始化降级状态标志
  - `check_redis()` / `check_mysql()`: 惰性恢复检测（30 秒间隔，参考 Design Doc §4.1）
  - `is_available()`: 判断 Redis 或 MySQL 至少一个可用
  - `initialize()`: 启动时调用，检测各后端可用性并输出日志
  - 全局单例 `get_storage_manager()` 工厂函数
- 本任务只实现骨架和降级检测，不包含具体读写逻辑

**验证**: 实例化 `StorageManager` 后，`check_redis()` / `check_mysql()` 在无服务环境下返回 `False`

---

### T9: StorageManager 读取逻辑（Redis → MySQL → SQLite）

**文件**: `app/storage_manager.py`（续写）

**操作**:
- 实现 `get_market_data(symbol, data_type, periods)` 方法：
  1. 检查 Redis 缓存（key 格式：`market_data:{symbol}:{period}`，参考 Design Doc §2.1）
  2. Redis 命中 → 反序列化 JSON 返回
  3. Redis 未命中 → 检查 MySQL 可用性
  4. MySQL 可用 → 从 MySQL 读取 → 回填 Redis（TTL 30 天）→ 返回
  5. MySQL 不可用 → 返回 `None`（由调用方降级到 SQLite）
- 实现 `get_symbol_timestamp(symbol, data_type)` 方法：
  - 类似逻辑，key 格式：`symbol_timestamps:{symbol}`

**验证**: 单元测试或手动测试：
- 无 Redis/MySQL 时返回 `None`
- 有 MySQL 时从 MySQL 读取并回填 Redis

---

### T10: StorageManager 写入逻辑 + 双写一致性

**文件**: `app/storage_manager.py`（续写）

**操作**:
- 实现 `save_market_data(symbol, data)` 方法（参考 Design Doc §3.2）：
  1. 删除 Redis 缓存（`market_data:{symbol}:{period}`）
  2. 写入 MySQL（事务）
  3. MySQL 成功 → 更新 Redis 缓存
  4. MySQL 失败 → 记录日志，返回 False（不更新 Redis）
  5. Redis 更新失败 → 仅记录日志，不影响返回
- 实现 `save_symbol_timestamp(symbol, data_type, refresh_time)` 方法：
  - 类似双写逻辑
- 实现 `delete_cache(symbol, periods)` 方法：
  - 批量删除 Redis 缓存键

**验证**: 
- MySQL 写入成功 → Redis 缓存同步更新
- MySQL 写入失败 → Redis 不更新，返回 False
- Redis 更新失败 → 不影响 MySQL 写入结果

---

### T11: 创建数据迁移脚本 app/migration.py

**文件**: `app/migration.py`（新建）

**操作**:
- 实现 `migrate_sqlite_to_mysql()` 函数（参考 Design Doc §6.1）：
  1. 创建 SQLite 引擎（复用 `DB_PATH`）
  2. 检查 MySQL `market_data` 表是否为空
  3. 为空时从 SQLite 读取全部 `market_data` 记录
  4. 批量写入 MySQL
  5. 同样迁移 `symbol_timestamps` 表
  6. 输出迁移记录数日志
- 幂等设计：MySQL 已有数据时跳过

**验证**: 
- MySQL 表为空时，迁移成功，数据条数一致
- MySQL 表非空时，跳过迁移，日志提示

---

### T12: 启动时自动触发迁移

**文件**: `app/main.py`（修改 lifespan）

**操作**:
- 在 T7 表结构初始化之后新增：
  ```python
  if mysql_ok:
      from app.migration import migrate_sqlite_to_mysql
      migrate_sqlite_to_mysql()
  ```

**验证**: 首次启动时日志输出迁移信息，再次启动时跳过迁移

---

### T13: 改造 cache.py 内部封装 StorageManager

**文件**: `app/services/cache.py`（修改）

**操作**:
- 改造 `save_market_data()` 函数（参考 Design Doc §5.1）：
  - 优先调用 `storage.save_market_data(symbol, data)` 双写
  - 失败时降级到现有 SQLite 写入逻辑 `_save_to_sqlite()`
- 改造 `get_cached_data()` 函数：
  - 优先调用 `storage.get_market_data(symbol, data_type, periods)` 读取
  - 返回 `None` 时降级到现有 SQLite 读取逻辑 `_get_from_sqlite()`
- 改造 `update_symbol_timestamp()` 和 `get_symbol_timestamp()`：
  - 类似封装，优先走 StorageManager
- 保持所有函数的外部签名不变，API 层零改动
- 现有 SQLite 逻辑提取为内部 `_save_to_sqlite()` / `_get_from_sqlite()` 私有函数

**验证**:
- 现有 API 接口 `/api/v1/data/latest/{symbol}` 返回数据格式不变
- 现有 API 接口 `/api/v1/data/batch-fetch` 行为不变
- 无 Redis/MySQL 时完全降级到 SQLite，行为与改造前一致

---

### T14: 验证 - Redis 缓存命中/未命中场景

**操作**:
- 启动 Redis + MySQL 服务
- 调用刷新接口写入数据
- 调用读取接口，验证首次从 MySQL 读取并回填 Redis
- 再次调用读取接口，验证从 Redis 缓存命中（检查日志）
- 等待 TTL 过期或手动删除 Redis key，验证回源 MySQL

**验证标准**:
- 日志中可见 "Redis 缓存命中" / "Redis 未命中，从 MySQL 读取" 信息
- 返回数据格式正确

---

### T15: 验证 - 降级到 MySQL / SQLite 场景

**操作**:
- 停止 Redis 服务，调用读取接口
- 验证自动降级到 MySQL，日志输出 "Redis 不可用"
- 停止 Redis + MySQL 服务，调用读取接口
- 验证降级到 SQLite，日志输出 "存储模式: SQLite"
- 恢复 Redis 服务，等待 30 秒后验证自动恢复

**验证标准**:
- 各降级场景下接口正常返回，无 500 错误
- 日志清晰标识当前存储模式

---

### T16: 验证 - 双写一致性 + 数据迁移完整性

**操作**:
- 双写一致性：
  1. 调用刷新接口写入数据
  2. 分别查询 MySQL 和 Redis，验证数据一致
  3. 模拟 Redis 写入失败（停止 Redis），验证 MySQL 数据不受影响
- 数据迁移完整性：
  1. 清空 MySQL 表
  2. 重启应用触发迁移
  3. 对比 SQLite 和 MySQL 数据条数一致

**验证标准**:
- MySQL 和 Redis 中数据内容一致
- Redis 故障不影响 MySQL 持久化
- 迁移前后数据条数一致

---

## 依赖关系图

```
T1 (依赖)  ──┐
T2 (配置)  ──┼── T3 (docker-compose)
             ├── T4 (mysql_database.py)
             └── T5 (redis_client.py)
                    │
T4 + T5 ─────── T6 (lifespan 初始化)
                    │
                 T7 (MySQL 建表)
                    │
                 T8 (StorageManager 骨架)
                   ╱ ╲
              T9      T10 (读/写逻辑)
                 ╲   ╱
                 T13 (cache.py 改造)
                   ╱ │ ╲
              T14  T15  T16 (验证)

T4 + T7 ──── T11 (迁移脚本) ──── T12 (启动迁移)
```

## 关键约束

1. **API 层零改动**: `app/api/data.py` 不修改，所有变更封装在 `cache.py` 和 `storage_manager.py` 内部
2. **SQLite 保留**: 不删除 SQLite 相关代码，作为最终兜底
3. **幂等迁移**: 数据迁移脚本可重复执行，MySQL 有数据时跳过
4. **渐进式降级**: 无 Redis/MySQL 时系统行为与改造前完全一致
