|
|
|
|
|
---
|
|
|
|
|
|
comet_change: storage-cache-refactor
|
|
|
|
|
|
role: technical-design
|
|
|
|
|
|
canonical_spec: openspec
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
# Storage Cache Refactor - Technical Design
|
|
|
|
|
|
|
|
|
|
|
|
## 1. 架构概览
|
|
|
|
|
|
|
|
|
|
|
|
### 1.1 目标架构
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
|
|
|
|
│ 应用层 │
|
|
|
|
|
|
│ app/api/data.py ←→ app/services/cache.py │
|
|
|
|
|
|
│ ↓ │
|
|
|
|
|
|
│ StorageManager │
|
|
|
|
|
|
│ (storage_manager.py) │
|
|
|
|
|
|
└─────────────────────────────────────────────────────────────┘
|
|
|
|
|
|
↓
|
|
|
|
|
|
┌─────────────────────┼─────────────────────┐
|
|
|
|
|
|
↓ ↓ ↓
|
|
|
|
|
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
|
|
|
|
|
│ Redis │ │ MySQL │ │ SQLite │
|
|
|
|
|
|
│ (缓存) │ │(持久化) │ │ (兜底) │
|
|
|
|
|
|
└─────────┘ └─────────┘ └─────────┘
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 1.2 核心组件
|
|
|
|
|
|
|
|
|
|
|
|
| 组件 | 文件 | 职责 |
|
|
|
|
|
|
|------|------|------|
|
|
|
|
|
|
| StorageManager | `app/storage_manager.py` | 封装三级存储逻辑,提供统一接口 |
|
|
|
|
|
|
| RedisClient | `app/redis_client.py` | Redis 连接池和客户端封装 |
|
|
|
|
|
|
| MySQLDatabase | `app/mysql_database.py` | MySQL 引擎和 SessionLocal |
|
|
|
|
|
|
| cache.py | `app/services/cache.py` | 保持现有函数签名,内部调用 StorageManager |
|
|
|
|
|
|
|
|
|
|
|
|
## 2. Redis 数据结构
|
|
|
|
|
|
|
|
|
|
|
|
### 2.1 行情数据缓存
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Key: market_data:{symbol}:{period}
|
|
|
|
|
|
Value: JSON {
|
|
|
|
|
|
"current_price": 123.45,
|
|
|
|
|
|
"timestamp": "2026-07-04T10:00:00",
|
|
|
|
|
|
"candles": [
|
|
|
|
|
|
{"datetime": "...", "open": ..., "high": ..., "low": ..., "close": ...}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
TTL: 30 天 (2592000 秒)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2.2 合约时间戳缓存
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Key: symbol_timestamps:{symbol}
|
|
|
|
|
|
Value: JSON {
|
|
|
|
|
|
"last_refresh_at": "2026-07-04T10:00:00",
|
|
|
|
|
|
"refresh_count": 42
|
|
|
|
|
|
}
|
|
|
|
|
|
TTL: 30 天 (2592000 秒)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2.3 设计理由
|
|
|
|
|
|
|
|
|
|
|
|
- 结构化键值存储,便于按品种和周期精确查询
|
|
|
|
|
|
- JSON 格式与当前 SQLite 存储格式兼容,迁移成本低
|
|
|
|
|
|
- TTL 自动清理,避免内存无限增长
|
|
|
|
|
|
|
|
|
|
|
|
## 3. 数据流设计
|
|
|
|
|
|
|
|
|
|
|
|
### 3.1 读取流程
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
请求行情数据
|
|
|
|
|
|
↓
|
|
|
|
|
|
检查 Redis 缓存
|
|
|
|
|
|
├─ 命中 → 返回数据
|
|
|
|
|
|
└─ 未命中 → 检查 MySQL 可用性
|
|
|
|
|
|
├─ 可用 → 读取 MySQL → 回填 Redis (TTL 30天) → 返回
|
|
|
|
|
|
└─ 不可用 → 读取 SQLite → 返回
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.2 写入流程(刷新接口)
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
刷新行情数据
|
|
|
|
|
|
↓
|
|
|
|
|
|
删除 Redis 缓存 (market_data:{symbol}:{period})
|
|
|
|
|
|
↓
|
|
|
|
|
|
写入 MySQL(事务)
|
|
|
|
|
|
├─ 成功 → 更新 Redis 缓存 → 返回成功
|
|
|
|
|
|
└─ 失败 → 返回错误(不更新 Redis)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 3.3 降级流程
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
StorageManager 检查存储后端可用性
|
|
|
|
|
|
↓
|
|
|
|
|
|
Redis 可用?
|
|
|
|
|
|
├─ 是 → 使用 Redis 缓存
|
|
|
|
|
|
└─ 否 → MySQL 可用?
|
|
|
|
|
|
├─ 是 → 使用 MySQL 持久化
|
|
|
|
|
|
└─ 否 → 使用 SQLite 兜底
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 4. 降级检测机制
|
|
|
|
|
|
|
|
|
|
|
|
### 4.1 惰性恢复策略
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
class StorageManager:
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.redis_available = False
|
|
|
|
|
|
self.mysql_available = False
|
|
|
|
|
|
self.last_redis_check = 0
|
|
|
|
|
|
self.last_mysql_check = 0
|
|
|
|
|
|
self.check_interval = 30 # 秒
|
|
|
|
|
|
|
|
|
|
|
|
def check_redis(self):
|
|
|
|
|
|
"""检查 Redis 可用性,30秒内不重复检测"""
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
if now - self.last_redis_check < self.check_interval:
|
|
|
|
|
|
return self.redis_available
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.redis_client.ping()
|
|
|
|
|
|
self.redis_available = True
|
|
|
|
|
|
logger.info("Redis 连接恢复")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
self.redis_available = False
|
|
|
|
|
|
logger.warning(f"Redis 不可用: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
self.last_redis_check = now
|
|
|
|
|
|
return self.redis_available
|
|
|
|
|
|
|
|
|
|
|
|
def check_mysql(self):
|
|
|
|
|
|
"""检查 MySQL 可用性,30秒内不重复检测"""
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
if now - self.last_mysql_check < self.check_interval:
|
|
|
|
|
|
return self.mysql_available
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with self.mysql_engine.connect() as conn:
|
|
|
|
|
|
conn.execute(text("SELECT 1"))
|
|
|
|
|
|
self.mysql_available = True
|
|
|
|
|
|
logger.info("MySQL 连接恢复")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
self.mysql_available = False
|
|
|
|
|
|
logger.warning(f"MySQL 不可用: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
self.last_mysql_check = now
|
|
|
|
|
|
return self.mysql_available
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 4.2 启动时初始化
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
# app/main.py lifespan
|
|
|
|
|
|
storage_manager = StorageManager()
|
|
|
|
|
|
storage_manager.initialize()
|
|
|
|
|
|
|
|
|
|
|
|
# 检测可用性
|
|
|
|
|
|
redis_ok = storage_manager.check_redis()
|
|
|
|
|
|
mysql_ok = storage_manager.check_mysql()
|
|
|
|
|
|
|
|
|
|
|
|
if redis_ok and mysql_ok:
|
|
|
|
|
|
logger.info("存储模式: Redis + MySQL")
|
|
|
|
|
|
elif mysql_ok:
|
|
|
|
|
|
logger.warning("存储模式: MySQL (Redis 不可用)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error("存储模式: SQLite (Redis 和 MySQL 均不可用)")
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 5. 集成方式
|
|
|
|
|
|
|
|
|
|
|
|
### 5.1 cache.py 内部封装
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
# app/services/cache.py
|
|
|
|
|
|
|
|
|
|
|
|
def get_cached_data(db, symbol, data_type, periods, end_time=None, max_candles=100):
|
|
|
|
|
|
"""从缓存中获取完整的多周期数据"""
|
|
|
|
|
|
storage = get_storage_manager()
|
|
|
|
|
|
|
|
|
|
|
|
# 优先从 Redis/MySQL 读取
|
|
|
|
|
|
if storage.is_available():
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = storage.get_market_data(symbol, data_type, periods)
|
|
|
|
|
|
if result:
|
|
|
|
|
|
return result
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"StorageManager 读取失败,降级到 SQLite: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 降级到 SQLite
|
|
|
|
|
|
return _get_from_sqlite(db, symbol, data_type, periods, end_time, max_candles)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_market_data(db, symbol, data):
|
|
|
|
|
|
"""保存采集结果到缓存"""
|
|
|
|
|
|
storage = get_storage_manager()
|
|
|
|
|
|
|
|
|
|
|
|
# 优先写入 Redis/MySQL
|
|
|
|
|
|
if storage.is_available():
|
|
|
|
|
|
try:
|
|
|
|
|
|
storage.save_market_data(symbol, data)
|
|
|
|
|
|
return
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"StorageManager 写入失败,降级到 SQLite: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# 降级到 SQLite
|
|
|
|
|
|
_save_to_sqlite(db, symbol, data)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 5.2 API 层零改动
|
|
|
|
|
|
|
|
|
|
|
|
- `app/api/data.py` 保持不变
|
|
|
|
|
|
- 所有接口仍使用 `db: Session = Depends(get_db)`
|
|
|
|
|
|
- cache.py 内部自动选择存储后端
|
|
|
|
|
|
|
|
|
|
|
|
## 6. 数据迁移
|
|
|
|
|
|
|
|
|
|
|
|
### 6.1 迁移策略
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
# app/migration.py
|
|
|
|
|
|
|
|
|
|
|
|
def migrate_sqlite_to_mysql():
|
|
|
|
|
|
"""从 SQLite 迁移历史数据到 MySQL"""
|
|
|
|
|
|
sqlite_engine = create_engine(f"sqlite:///{DB_PATH}")
|
|
|
|
|
|
mysql_engine = create_mysql_engine()
|
|
|
|
|
|
|
|
|
|
|
|
# 检查 MySQL 表是否为空
|
|
|
|
|
|
with mysql_engine.connect() as conn:
|
|
|
|
|
|
result = conn.execute(text("SELECT COUNT(*) FROM market_data"))
|
|
|
|
|
|
count = result.scalar()
|
|
|
|
|
|
|
|
|
|
|
|
if count > 0:
|
|
|
|
|
|
logger.info("MySQL 已有数据,跳过迁移")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# 从 SQLite 读取数据
|
|
|
|
|
|
with sqlite_engine.connect() as conn:
|
|
|
|
|
|
result = conn.execute(text("SELECT * FROM market_data"))
|
|
|
|
|
|
rows = result.fetchall()
|
|
|
|
|
|
|
|
|
|
|
|
# 写入 MySQL
|
|
|
|
|
|
with mysql_engine.begin() as conn:
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
conn.execute(
|
|
|
|
|
|
text("INSERT INTO market_data ..."),
|
|
|
|
|
|
{...}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"数据迁移完成,共迁移 {len(rows)} 条记录")
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 6.2 迁移触发时机
|
|
|
|
|
|
|
|
|
|
|
|
- 应用启动时自动检测
|
|
|
|
|
|
- MySQL 表为空时触发迁移
|
|
|
|
|
|
- 迁移完成后输出日志
|
|
|
|
|
|
|
|
|
|
|
|
## 7. 配置项
|
|
|
|
|
|
|
|
|
|
|
|
### 7.1 新增配置
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
# app/config.py
|
|
|
|
|
|
|
|
|
|
|
|
# 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", "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")
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 7.2 docker-compose.yml 新增服务
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
services:
|
|
|
|
|
|
redis:
|
|
|
|
|
|
image: redis:7-alpine
|
|
|
|
|
|
ports:
|
|
|
|
|
|
- "6379:6379"
|
|
|
|
|
|
volumes:
|
|
|
|
|
|
- redis-data:/data
|
|
|
|
|
|
|
|
|
|
|
|
mysql:
|
|
|
|
|
|
image: mysql:8.0
|
|
|
|
|
|
environment:
|
|
|
|
|
|
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD}
|
|
|
|
|
|
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
|
|
|
|
|
ports:
|
|
|
|
|
|
- "3306:3306"
|
|
|
|
|
|
volumes:
|
|
|
|
|
|
- mysql-data:/var/lib/mysql
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 8. 测试策略
|
|
|
|
|
|
|
|
|
|
|
|
### 8.1 单元测试
|
|
|
|
|
|
|
|
|
|
|
|
- StorageManager 各方法独立测试
|
|
|
|
|
|
- Redis 缓存命中/未命中场景
|
|
|
|
|
|
- MySQL 读写场景
|
|
|
|
|
|
- 降级逻辑场景
|
|
|
|
|
|
|
|
|
|
|
|
### 8.2 集成测试
|
|
|
|
|
|
|
|
|
|
|
|
- Redis + MySQL 正常模式
|
|
|
|
|
|
- Redis 不可用降级到 MySQL
|
|
|
|
|
|
- Redis + MySQL 均不可用降级到 SQLite
|
|
|
|
|
|
- 刷新接口双写一致性
|
|
|
|
|
|
|
|
|
|
|
|
### 8.3 故障注入
|
|
|
|
|
|
|
|
|
|
|
|
- 模拟 Redis 服务停止
|
|
|
|
|
|
- 模拟 MySQL 服务停止
|
|
|
|
|
|
- 验证降级和恢复逻辑
|
|
|
|
|
|
|
|
|
|
|
|
### 8.4 性能测试
|
|
|
|
|
|
|
|
|
|
|
|
- 对比改造前后读取延迟
|
|
|
|
|
|
- 验证 Redis 缓存命中率
|
|
|
|
|
|
- 监控 MySQL 查询性能
|
|
|
|
|
|
|
|
|
|
|
|
## 9. 风险与缓解
|
|
|
|
|
|
|
|
|
|
|
|
| 风险 | 影响 | 缓解措施 |
|
|
|
|
|
|
|------|------|---------|
|
|
|
|
|
|
| Redis 内存占用过高 | 系统内存不足 | TTL 30 天自动清理,监控内存使用 |
|
|
|
|
|
|
| 双写一致性 | MySQL 成功但 Redis 失败 | Redis 失败仅记录日志,不影响持久化 |
|
|
|
|
|
|
| 降级检测延迟 | 恢复不及时 | 30 秒惰性恢复阈值,平衡性能和实时性 |
|
|
|
|
|
|
| 数据迁移失败 | 历史数据丢失 | 保留 SQLite 兜底,可手动回滚 |
|
|
|
|
|
|
| MySQL 部署复杂度 | 运维成本增加 | docker-compose 一键部署 |
|
|
|
|
|
|
|
|
|
|
|
|
## 10. 实施计划
|
|
|
|
|
|
|
|
|
|
|
|
### 10.1 阶段划分
|
|
|
|
|
|
|
|
|
|
|
|
1. **依赖与配置**: 添加 redis、pymysql 依赖,新增配置项
|
|
|
|
|
|
2. **数据库模型**: 创建 Redis/MySQL 连接模块
|
|
|
|
|
|
3. **StorageManager**: 实现三级存储逻辑
|
|
|
|
|
|
4. **集成改造**: 改造 cache.py,集成 StorageManager
|
|
|
|
|
|
5. **数据迁移**: 实现 SQLite → MySQL 迁移
|
|
|
|
|
|
6. **测试验证**: 单元测试、集成测试、故障注入
|
|
|
|
|
|
|
|
|
|
|
|
### 10.2 验收标准
|
|
|
|
|
|
|
|
|
|
|
|
- Redis 缓存命中时,读取延迟 < 10ms
|
|
|
|
|
|
- Redis 未命中时,从 MySQL 读取并回填
|
|
|
|
|
|
- Redis 不可用时,自动降级到 MySQL
|
|
|
|
|
|
- Redis + MySQL 均不可用时,降级到 SQLite
|
|
|
|
|
|
- 刷新接口双写成功,数据一致性保证
|
|
|
|
|
|
- 数据迁移完整,历史数据不丢失
|