You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
buffer_platform/docs/superpowers/plans/2026-07-04-storage-cache-re...

448 lines
14 KiB

---
change: storage-cache-refactor
design-doc: docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md
base-ref: 8b5e43f4915cef786208a8132460f87b8b191765
---
# Storage Cache Refactor - 实施计划
> 基于 [Design Doc](docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md) 拆分的可执行任务。
> 目标:引入 Redis缓存+ MySQL持久化+ SQLite兜底三级存储架构改造现有 cache.py 内部封装 StorageManagerAPI 层零改动。
---
## 任务总览
| # | 任务 | 预估 | 依赖 |
|---|------|------|------|
| 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 读取 → 回填 RedisTTL 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 时系统行为与改造前完全一致