From a85f61a9277d27884b6b11ee731c93daef5a85b6 Mon Sep 17 00:00:00 2001 From: Lxy Date: Sat, 4 Jul 2026 09:27:05 +0800 Subject: [PATCH] docs: add storage-cache-refactor openspec artifacts, design doc and implementation plan --- .comet/config.yaml | 6 + .../2026-07-04-storage-cache-refactor.md | 447 ++++++++++++++++++ ...026-07-04-storage-cache-refactor-design.md | 367 ++++++++++++++ .../storage-cache-refactor/.comet.yaml | 22 + .../.comet/handoff/brainstorm-summary.md | 46 ++ .../.comet/handoff/design-context.json | 17 + .../.comet/handoff/design-context.md | 387 +++++++++++++++ .../storage-cache-refactor/.openspec.yaml | 2 + .../changes/storage-cache-refactor/design.md | 121 +++++ .../storage-cache-refactor/proposal.md | 33 ++ .../specs/dual-write-consistency/spec.md | 30 ++ .../specs/mysql-persistence/spec.md | 41 ++ .../specs/redis-cache-layer/spec.md | 41 ++ .../specs/storage-fallback/spec.md | 38 ++ .../changes/storage-cache-refactor/tasks.md | 46 ++ 15 files changed, 1644 insertions(+) create mode 100644 .comet/config.yaml create mode 100644 docs/superpowers/plans/2026-07-04-storage-cache-refactor.md create mode 100644 docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md create mode 100644 openspec/changes/storage-cache-refactor/.comet.yaml create mode 100644 openspec/changes/storage-cache-refactor/.comet/handoff/brainstorm-summary.md create mode 100644 openspec/changes/storage-cache-refactor/.comet/handoff/design-context.json create mode 100644 openspec/changes/storage-cache-refactor/.comet/handoff/design-context.md create mode 100644 openspec/changes/storage-cache-refactor/.openspec.yaml create mode 100644 openspec/changes/storage-cache-refactor/design.md create mode 100644 openspec/changes/storage-cache-refactor/proposal.md create mode 100644 openspec/changes/storage-cache-refactor/specs/dual-write-consistency/spec.md create mode 100644 openspec/changes/storage-cache-refactor/specs/mysql-persistence/spec.md create mode 100644 openspec/changes/storage-cache-refactor/specs/redis-cache-layer/spec.md create mode 100644 openspec/changes/storage-cache-refactor/specs/storage-fallback/spec.md create mode 100644 openspec/changes/storage-cache-refactor/tasks.md diff --git a/.comet/config.yaml b/.comet/config.yaml new file mode 100644 index 0000000..89361ba --- /dev/null +++ b/.comet/config.yaml @@ -0,0 +1,6 @@ +# context_compression: off | beta +context_compression: off +# review_mode: off | standard | thorough +review_mode: off +# auto_transition: true | false +auto_transition: true diff --git a/docs/superpowers/plans/2026-07-04-storage-cache-refactor.md b/docs/superpowers/plans/2026-07-04-storage-cache-refactor.md new file mode 100644 index 0000000..90bde76 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-storage-cache-refactor.md @@ -0,0 +1,447 @@ +--- +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 内部封装 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", "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 时系统行为与改造前完全一致 diff --git a/docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md b/docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md new file mode 100644 index 0000000..916c625 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md @@ -0,0 +1,367 @@ +--- +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 +- 刷新接口双写成功,数据一致性保证 +- 数据迁移完整,历史数据不丢失 diff --git a/openspec/changes/storage-cache-refactor/.comet.yaml b/openspec/changes/storage-cache-refactor/.comet.yaml new file mode 100644 index 0000000..12438de --- /dev/null +++ b/openspec/changes/storage-cache-refactor/.comet.yaml @@ -0,0 +1,22 @@ +workflow: full +phase: build +context_compression: off +build_mode: subagent-driven-development +build_pause: null +subagent_dispatch: confirmed +tdd_mode: tdd +review_mode: thorough +isolation: worktree +verify_mode: null +auto_transition: true +base_ref: 8b5e43f4915cef786208a8132460f87b8b191765 +design_doc: docs/superpowers/specs/2026-07-04-storage-cache-refactor-design.md +plan: docs/superpowers/plans/2026-07-04-storage-cache-refactor.md +verify_result: pending +verification_report: null +branch_status: pending +created_at: 2026-07-04 +verified_at: null +archived: false +handoff_context: openspec/changes/storage-cache-refactor/.comet/handoff/design-context.json +handoff_hash: 1064fdf5186e511848878541bdad663ac6b521ee5721807d01eaf3481bb0cd5a diff --git a/openspec/changes/storage-cache-refactor/.comet/handoff/brainstorm-summary.md b/openspec/changes/storage-cache-refactor/.comet/handoff/brainstorm-summary.md new file mode 100644 index 0000000..799bddf --- /dev/null +++ b/openspec/changes/storage-cache-refactor/.comet/handoff/brainstorm-summary.md @@ -0,0 +1,46 @@ +# Brainstorm Summary + +- Change: storage-cache-refactor +- Date: 2026-07-04 + +## 确认的技术方案 + +1. **Redis 数据结构**: 方案 C — 结构化键值存储 + - `market_data:{symbol}:{period}` → JSON(含 current_price, timestamp, candles) + - `symbol_timestamps:{symbol}` → JSON(含 last_refresh_at, refresh_count) + - TTL: 30 天 + +2. **集成方式**: 方案 B — cache.py 内部封装 + - cache.py 函数签名不变(仍接收 `db: Session`) + - 函数内部判断:优先走 Redis/MySQL,降级时走原有 SQLite 逻辑 + - API 层零改动,渐进式迁移 + +3. **降级检测**: 方案 C — 惰性恢复 + - 启动时检测 Redis/MySQL 可用性 + - 运行时捕获异常标记不可用 + - 下次请求时距上次检测超 30 秒则尝试重连 + - 重连成功恢复,失败继续降级 + +4. **MySQL 驱动**: pymysql(纯 Python,Windows 零障碍部署) + +5. **双写策略**: 同步双写,先写 MySQL 再更新 Redis + - 写入流程:删 Redis 缓存 → 写 MySQL → 更新 Redis + - MySQL 失败回滚,Redis 失败仅记录日志 + +## 关键取舍与风险 + +- Redis 内存占用 → TTL 30 天自动清理 +- 双写一致性 → MySQL 优先,Redis 失败不影响持久化 +- 降级检测延迟 → 30 秒惰性恢复阈值 +- 数据迁移 → 启动时自动从 SQLite 迁移到 MySQL,保留 SQLite 兜底 + +## 测试策略 + +- 单元测试:StorageManager 各方法 +- 集成测试:三级降级场景 +- 故障注入:模拟 Redis/MySQL 不可用 +- 性能对比:改造前后读取延迟 + +## Spec Patch + +无(现有 delta spec 已覆盖所有验收场景) diff --git a/openspec/changes/storage-cache-refactor/.comet/handoff/design-context.json b/openspec/changes/storage-cache-refactor/.comet/handoff/design-context.json new file mode 100644 index 0000000..63a8485 --- /dev/null +++ b/openspec/changes/storage-cache-refactor/.comet/handoff/design-context.json @@ -0,0 +1,17 @@ +{ + "change": "storage-cache-refactor", + "phase": "design", + "mode": "compact", + "canonical_spec": "openspec", + "generated_by": "comet-handoff.sh", + "context_hash": "1064fdf5186e511848878541bdad663ac6b521ee5721807d01eaf3481bb0cd5a", + "files": [ + { "path": "openspec/changes/storage-cache-refactor/proposal.md", "sha256": "759de20d4e94b3228ee94d8607740de2949c24935933884584b85d3d855569ad" }, + { "path": "openspec/changes/storage-cache-refactor/design.md", "sha256": "293fba52d3bef2adc55fa3d8667879fc7806918922f2f273d05efbc870c862ac" }, + { "path": "openspec/changes/storage-cache-refactor/tasks.md", "sha256": "295733c66c5b2599cac51356e7344d17c579e674e667ec24c6b77d62ee519fcd" }, + { "path": "openspec/changes/storage-cache-refactor/specs/dual-write-consistency/spec.md", "sha256": "6fd0c00433c73329adc93f97ea5a8b744d9a74148d7db40f30c843e92480b91e" }, + { "path": "openspec/changes/storage-cache-refactor/specs/mysql-persistence/spec.md", "sha256": "f23680f51345a871dddbf3d18eacf6a53de1a20945d02516ffc55a2eb500af75" }, + { "path": "openspec/changes/storage-cache-refactor/specs/redis-cache-layer/spec.md", "sha256": "a0b5c7023c88339957ce5576ad326474fd0ab913756c223573766cbc9e23f761" }, + { "path": "openspec/changes/storage-cache-refactor/specs/storage-fallback/spec.md", "sha256": "feaf531250861c109bc48d06d684ff2520cb48269397211bdcefe7cda9013714" } + ] +} diff --git a/openspec/changes/storage-cache-refactor/.comet/handoff/design-context.md b/openspec/changes/storage-cache-refactor/.comet/handoff/design-context.md new file mode 100644 index 0000000..2d77550 --- /dev/null +++ b/openspec/changes/storage-cache-refactor/.comet/handoff/design-context.md @@ -0,0 +1,387 @@ +# Comet Design Handoff + +- Change: storage-cache-refactor +- Phase: design +- Mode: compact +- Context hash: 1064fdf5186e511848878541bdad663ac6b521ee5721807d01eaf3481bb0cd5a + +Generated-by: comet-handoff.sh + +OpenSpec remains the canonical capability spec. This handoff is a deterministic, source-traceable context pack, not an agent-authored summary. + +## openspec/changes/storage-cache-refactor/proposal.md + +- Source: openspec/changes/storage-cache-refactor/proposal.md +- Lines: 1-33 +- SHA256: 759de20d4e94b3228ee94d8607740de2949c24935933884584b85d3d855569ad + +```md +## Why + +当前系统使用 SQLite 作为唯一存储后端,无法满足行情数据高频读写的性能需求。每次刷新行情数据都需要直接读写磁盘文件,导致响应延迟高、并发能力受限。需要引入更高效的存储方案以提升系统吞吐量和用户体验。 + +## What Changes + +- 引入 Redis 作为热数据缓存层,行情数据优先从 Redis 读取(TTL 30 天) +- 引入 MySQL 作为持久化存储层,替代 SQLite 承担主要持久化职责 +- 保留 SQLite 作为最终兜底方案,当 Redis 和 MySQL 均不可用时启用 +- 新增数据访问层:实现 Redis → MySQL → SQLite 的三级降级读取策略 +- 刷新接口改造:数据同步写入 Redis 和 MySQL,保证双写一致性 +- 启动流程改造:初始化 Redis/MySQL/SQLite 连接,检测可用性 + +## Capabilities + +### New Capabilities + +- `redis-cache-layer`: Redis 缓存读写能力,包括热数据缓存、TTL 管理、缓存失效策略 +- `mysql-persistence`: MySQL 持久化存储能力,替代 SQLite 承担主要持久化职责 +- `storage-fallback`: 三级降级策略(Redis → MySQL → SQLite),保证系统可用性 +- `dual-write-consistency`: 刷新接口双写能力,保证 Redis 和 MySQL 数据一致性 + +### Modified Capabilities + +(无既有 spec 需要修改) + +## Impact + +- **代码**: `app/database.py`、`app/models.py`、`app/api/data.py`、`app/services/collector.py`、`app/main.py` +- **依赖**: 新增 `redis`、`pymysql`(或 `aiomysql`)Python 包 +- **配置**: `app/config.py` 新增 Redis/MySQL 连接配置 +- **部署**: `docker-compose.yml` 新增 Redis 和 MySQL 服务 +- **数据库**: 新增 MySQL 数据库初始化脚本,保留 SQLite 作为兜底 +``` + +## openspec/changes/storage-cache-refactor/design.md + +- Source: openspec/changes/storage-cache-refactor/design.md +- Lines: 1-121 +- SHA256: 293fba52d3bef2adc55fa3d8667879fc7806918922f2f273d05efbc870c862ac + +[TRUNCATED] + +```md +## Context + +当前系统使用 SQLite 作为唯一存储后端,所有行情数据(`MarketData`)以 JSON 字符串形式存储在磁盘文件中。每次刷新行情时,系统需要从数据源采集数据、写入 SQLite,读取时再反序列化。这种架构在高频读写场景下存在性能瓶颈。 + +系统包含以下核心实体: +- `MarketData`: 行情 K 线数据(高频读写,数据量大) +- `SymbolTimestamp`: 合约数据时间戳(中频读写) +- `ScheduledTask`: 定时任务配置(低频读写) +- 用户认证、交易复盘等低频业务表 + +约束条件: +- 单机部署,需要本地运行 Redis +- 需要保证系统可用性,即使部分存储组件故障 +- 现有 SQLite 数据需要迁移或兼容 + +## Goals / Non-Goals + +**Goals:** +- 引入 Redis 作为热数据缓存层,提升行情数据读取性能(TTL 30 天) +- 引入 MySQL 作为主要持久化存储,替代 SQLite 承担日常读写 +- 保留 SQLite 作为最终兜底方案,保证系统可用性 +- 实现三级降级策略:Redis → MySQL → SQLite +- 刷新接口双写:数据同步写入 Redis 和 MySQL +- 启动时初始化多存储后端,检测可用性 + +**Non-Goals:** +- 不迁移用户认证、交易复盘等低频业务表(保持 SQLite) +- 不改造前端页面 +- 不统一 API 接口规范(留给后续 change) +- 不实现分布式部署或集群方案 + +## Decisions + +### 1. 存储分层策略 + +**决策**: 采用 Redis(缓存)+ MySQL(持久化)+ SQLite(兜底)三级架构。 + +**理由**: +- Redis 提供亚毫秒级读取性能,适合行情数据高频访问 +- MySQL 提供可靠的持久化能力和事务支持,替代 SQLite 成为主存储 +- SQLite 保留作为最终兜底,保证极端情况下系统仍可运行 +- 三级降级保证系统可用性,避免单点故障 + +**备选方案**: +- Redis + PostgreSQL: PostgreSQL 功能更强大,但部署复杂度更高,MySQL 更轻量 +- 纯 Redis: 无法满足持久化需求,数据丢失风险高 +- 纯 MySQL: 缺少缓存层,读取性能提升有限 + +### 2. 数据访问层设计 + +**决策**: 新增 `StorageManager` 抽象层,封装三级降级逻辑。 + +**理由**: +- 业务代码无需关心底层存储实现 +- 降级策略集中管理,便于维护和测试 +- 可通过配置切换存储后端,支持渐进式迁移 + +**备选方案**: +- 直接在业务代码中实现降级逻辑: 代码重复,难以维护 +- 使用 ORM 插件: 灵活性不足,难以实现复杂的降级策略 + +### 3. 双写一致性策略 + +**决策**: 刷新接口采用同步双写,先写 MySQL 再更新 Redis。 + +**理由**: +- MySQL 作为持久化主存储,必须先保证数据落盘 +- Redis 作为缓存,写入失败不影响数据持久化 +- 同步双写保证数据一致性,避免异步延迟导致的数据不一致 + +**备选方案**: +- 异步双写: 性能更好,但可能出现数据不一致 +- 仅写 MySQL,Redis 通过缓存未命中回填: 首次读取性能差 + +### 4. Redis 缓存策略 + +**决策**: 行情数据 TTL 30 天,缓存未命中时从 MySQL 读取并回填。 + +**理由**: +- 30 天覆盖大部分行情数据的活跃访问周期 +``` + +Full source: openspec/changes/storage-cache-refactor/design.md + +## openspec/changes/storage-cache-refactor/tasks.md + +- Source: openspec/changes/storage-cache-refactor/tasks.md +- Lines: 1-46 +- SHA256: 295733c66c5b2599cac51356e7344d17c579e674e667ec24c6b77d62ee519fcd + +```md +## 1. 依赖与配置 + +- [ ] 1.1 添加 `redis`、`pymysql` 依赖到 `requirements.txt` +- [ ] 1.2 在 `app/config.py` 中新增 Redis 和 MySQL 连接配置项(host、port、user、password、database) +- [ ] 1.3 在 `docker-compose.yml` 中新增 Redis 和 MySQL 服务定义 + +## 2. 数据库模型与初始化 + +- [ ] 2.1 创建 `app/mysql_database.py`,实现 MySQL 连接引擎和 SessionLocal +- [ ] 2.2 创建 `app/redis_client.py`,实现 Redis 连接池和客户端封装 +- [ ] 2.3 在 `app/main.py` 的 `lifespan` 中初始化 Redis 和 MySQL 连接,检测可用性 +- [ ] 2.4 创建 MySQL 表结构初始化脚本(复用现有 ORM 模型) + +## 3. 数据迁移 + +- [ ] 3.1 创建数据迁移脚本,从 SQLite 读取历史数据并写入 MySQL +- [ ] 3.2 在应用启动时检测 MySQL 表是否为空,自动触发迁移 + +## 4. 存储管理层 + +- [ ] 4.1 创建 `app/storage_manager.py`,实现 `StorageManager` 抽象层 +- [ ] 4.2 实现 Redis 缓存读取逻辑(命中返回,未命中回源 MySQL 并回填) +- [ ] 4.3 实现 MySQL 持久化读写逻辑 +- [ ] 4.4 实现三级降级策略(Redis → MySQL → SQLite) +- [ ] 4.5 实现降级状态检测和恢复机制 + +## 5. 双写与缓存一致性 + +- [ ] 5.1 实现刷新接口双写逻辑:先删 Redis 缓存,再写 MySQL,最后更新 Redis +- [ ] 5.2 实现双写顺序保证和错误处理(MySQL 失败回滚,Redis 失败记录日志) +- [ ] 5.3 实现缓存未命中回填逻辑(TTL 30 天) + +## 6. 接口改造 + +- [ ] 6.1 改造 `app/api/data.py` 中的行情数据读取接口,使用 `StorageManager` +- [ ] 6.2 改造 `app/api/data.py` 中的刷新接口,使用双写逻辑 +- [ ] 6.3 改造 `app/services/collector.py`,使用 `StorageManager` 写入数据 + +## 7. 测试与验证 + +- [ ] 7.1 验证 Redis 缓存命中场景 +- [ ] 7.2 验证 Redis 缓存未命中回源 MySQL 场景 +- [ ] 7.3 验证 Redis 不可用降级到 MySQL 场景 +- [ ] 7.4 验证 Redis 和 MySQL 均不可用降级到 SQLite 场景 +- [ ] 7.5 验证刷新接口双写一致性 +- [ ] 7.6 验证数据迁移完整性 +``` + +## openspec/changes/storage-cache-refactor/specs/dual-write-consistency/spec.md + +- Source: openspec/changes/storage-cache-refactor/specs/dual-write-consistency/spec.md +- Lines: 1-30 +- SHA256: 6fd0c00433c73329adc93f97ea5a8b744d9a74148d7db40f30c843e92480b91e + +```md +## ADDED Requirements + +### Requirement: 刷新接口双写 +系统应当在刷新接口中实现 Redis 和 MySQL 的同步双写,保证数据一致性。 + +#### Scenario: 刷新接口双写成功 +- **WHEN** 用户调用刷新接口更新行情数据 +- **THEN** 系统先删除 Redis 缓存,再写入 MySQL,最后更新 Redis 缓存 + +#### Scenario: MySQL 写入失败 +- **WHEN** 刷新接口写入 MySQL 失败 +- **THEN** 系统返回错误响应,不更新 Redis 缓存,保证数据一致性 + +#### Scenario: Redis 写入失败 +- **WHEN** 刷新接口写入 MySQL 成功但 Redis 写入失败 +- **THEN** 系统返回成功响应(MySQL 数据已持久化),输出 Redis 写入失败警告日志 + +### Requirement: 双写顺序保证 +系统应当保证双写的顺序:先写 MySQL,再更新 Redis。 + +#### Scenario: 双写顺序 +- **WHEN** 刷新接口执行双写 +- **THEN** 系统先写入 MySQL 并确认成功,再更新 Redis 缓存 + +### Requirement: 缓存回填一致性 +系统应当在缓存未命中回填时保证回填数据与 MySQL 数据一致。 + +#### Scenario: 缓存未命中回填 +- **WHEN** Redis 缓存未命中且从 MySQL 读取数据 +- **THEN** 系统将 MySQL 数据回填到 Redis,TTL 设置为 30 天 +``` + +## openspec/changes/storage-cache-refactor/specs/mysql-persistence/spec.md + +- Source: openspec/changes/storage-cache-refactor/specs/mysql-persistence/spec.md +- Lines: 1-41 +- SHA256: f23680f51345a871dddbf3d18eacf6a53de1a20945d02516ffc55a2eb500af75 + +```md +## ADDED Requirements + +### Requirement: MySQL 连接管理 +系统应当提供 MySQL 连接管理能力,包括连接初始化、连接池管理和表结构初始化。 + +#### Scenario: MySQL 连接初始化成功 +- **WHEN** 应用启动且 MySQL 配置有效 +- **THEN** 系统成功建立 MySQL 连接并创建必要的表结构 + +#### Scenario: MySQL 连接初始化失败 +- **WHEN** 应用启动但 MySQL 服务不可用 +- **THEN** 系统输出错误日志并标记 MySQL 为不可用状态,但不阻止应用启动 + +### Requirement: 行情数据持久化存储 +系统应当使用 MySQL 作为行情数据的主要持久化存储,替代 SQLite。 + +#### Scenario: 行情数据写入 MySQL +- **WHEN** 刷新接口接收到行情数据更新请求 +- **THEN** 系统将数据写入 MySQL 的 `market_data` 表 + +#### Scenario: 行情数据从 MySQL 读取 +- **WHEN** Redis 缓存未命中且 MySQL 可用 +- **THEN** 系统从 MySQL 读取行情数据并返回 + +### Requirement: MySQL 表结构迁移 +系统应当提供从 SQLite 到 MySQL 的数据迁移能力,保证历史数据不丢失。 + +#### Scenario: 首次启动数据迁移 +- **WHEN** 应用首次启动且 MySQL 可用但表为空 +- **THEN** 系统从 SQLite 读取历史数据并迁移到 MySQL + +#### Scenario: 迁移完成 +- **WHEN** 数据迁移完成 +- **THEN** 系统输出迁移成功日志,后续读写直接操作 MySQL + +### Requirement: MySQL 事务支持 +系统应当使用 MySQL 事务保证数据写入的原子性和一致性。 + +#### Scenario: 批量写入事务 +- **WHEN** 刷新接口需要写入多个品种的行情数据 +- **THEN** 系统使用事务保证所有数据要么全部写入成功,要么全部回滚 +``` + +## openspec/changes/storage-cache-refactor/specs/redis-cache-layer/spec.md + +- Source: openspec/changes/storage-cache-refactor/specs/redis-cache-layer/spec.md +- Lines: 1-41 +- SHA256: a0b5c7023c88339957ce5576ad326474fd0ab913756c223573766cbc9e23f761 + +```md +## ADDED Requirements + +### Requirement: Redis 缓存连接管理 +系统应当提供 Redis 连接管理能力,包括连接初始化、健康检查和连接池管理。 + +#### Scenario: Redis 连接初始化成功 +- **WHEN** 应用启动且 Redis 配置有效 +- **THEN** 系统成功建立 Redis 连接并输出初始化成功日志 + +#### Scenario: Redis 连接初始化失败 +- **WHEN** 应用启动但 Redis 服务不可用 +- **THEN** 系统输出警告日志并标记 Redis 为不可用状态,但不阻止应用启动 + +### Requirement: 行情数据缓存读取 +系统应当优先从 Redis 读取行情数据,缓存未命中时回源 MySQL。 + +#### Scenario: Redis 缓存命中 +- **WHEN** 用户请求行情数据且 Redis 中存在该数据 +- **THEN** 系统直接从 Redis 返回数据,不访问 MySQL + +#### Scenario: Redis 缓存未命中 +- **WHEN** 用户请求行情数据但 Redis 中不存在该数据 +- **THEN** 系统从 MySQL 读取数据,回填到 Redis(TTL 30 天),并返回数据 + +### Requirement: Redis 缓存 TTL 管理 +系统应当为缓存数据设置 30 天过期时间,过期后自动清理。 + +#### Scenario: 缓存数据过期 +- **WHEN** 缓存数据超过 30 天未访问 +- **THEN** Redis 自动清理该数据,下次访问时从 MySQL 重新加载 + +#### Scenario: 缓存数据续期 +- **WHEN** 用户访问已缓存的行情数据 +- **THEN** 系统返回缓存数据,TTL 保持为 30 天(不续期) + +### Requirement: Redis 缓存失效 +系统应当在数据更新时主动失效相关缓存,保证数据一致性。 + +#### Scenario: 刷新接口触发缓存失效 +- **WHEN** 用户调用刷新接口更新行情数据 +- **THEN** 系统先删除 Redis 中对应的缓存数据,再执行双写流程 +``` + +## openspec/changes/storage-cache-refactor/specs/storage-fallback/spec.md + +- Source: openspec/changes/storage-cache-refactor/specs/storage-fallback/spec.md +- Lines: 1-38 +- SHA256: feaf531250861c109bc48d06d684ff2520cb48269397211bdcefe7cda9013714 + +```md +## ADDED Requirements + +### Requirement: 三级降级策略 +系统应当实现 Redis → MySQL → SQLite 的三级降级读取策略,保证系统可用性。 + +#### Scenario: 正常模式(Redis + MySQL 可用) +- **WHEN** Redis 和 MySQL 均可用 +- **THEN** 系统优先从 Redis 读取,未命中时回源 MySQL + +#### Scenario: Redis 不可用降级 +- **WHEN** Redis 服务不可用但 MySQL 可用 +- **THEN** 系统直接从 MySQL 读取数据,跳过 Redis 缓存层 + +#### Scenario: Redis 和 MySQL 均不可用降级 +- **WHEN** Redis 和 MySQL 均不可用 +- **THEN** 系统降级到 SQLite,从 SQLite 读取数据并保证基础服务可用 + +### Requirement: 降级状态检测 +系统应当能够检测存储后端的可用性状态,并在降级时输出明确的日志。 + +#### Scenario: 启动时降级检测 +- **WHEN** 应用启动时检测到 Redis 或 MySQL 不可用 +- **THEN** 系统输出降级警告日志,说明当前使用的存储模式 + +#### Scenario: 运行时降级触发 +- **WHEN** 运行中 Redis 或 MySQL 连接异常 +- **THEN** 系统自动切换到降级模式,输出降级日志,并继续提供服务 + +### Requirement: 降级恢复 +系统应当能够在存储后端恢复后自动恢复到正常模式。 + +#### Scenario: Redis 恢复 +- **WHEN** Redis 从不可用状态恢复 +- **THEN** 系统自动恢复到 Redis + MySQL 模式,后续读取优先使用 Redis + +#### Scenario: MySQL 恢复 +- **WHEN** MySQL 从不可用状态恢复 +- **THEN** 系统自动恢复到 Redis + MySQL 模式,SQLite 降级结束 +``` + diff --git a/openspec/changes/storage-cache-refactor/.openspec.yaml b/openspec/changes/storage-cache-refactor/.openspec.yaml new file mode 100644 index 0000000..d86f152 --- /dev/null +++ b/openspec/changes/storage-cache-refactor/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-04 diff --git a/openspec/changes/storage-cache-refactor/design.md b/openspec/changes/storage-cache-refactor/design.md new file mode 100644 index 0000000..f980e71 --- /dev/null +++ b/openspec/changes/storage-cache-refactor/design.md @@ -0,0 +1,121 @@ +## Context + +当前系统使用 SQLite 作为唯一存储后端,所有行情数据(`MarketData`)以 JSON 字符串形式存储在磁盘文件中。每次刷新行情时,系统需要从数据源采集数据、写入 SQLite,读取时再反序列化。这种架构在高频读写场景下存在性能瓶颈。 + +系统包含以下核心实体: +- `MarketData`: 行情 K 线数据(高频读写,数据量大) +- `SymbolTimestamp`: 合约数据时间戳(中频读写) +- `ScheduledTask`: 定时任务配置(低频读写) +- 用户认证、交易复盘等低频业务表 + +约束条件: +- 单机部署,需要本地运行 Redis +- 需要保证系统可用性,即使部分存储组件故障 +- 现有 SQLite 数据需要迁移或兼容 + +## Goals / Non-Goals + +**Goals:** +- 引入 Redis 作为热数据缓存层,提升行情数据读取性能(TTL 30 天) +- 引入 MySQL 作为主要持久化存储,替代 SQLite 承担日常读写 +- 保留 SQLite 作为最终兜底方案,保证系统可用性 +- 实现三级降级策略:Redis → MySQL → SQLite +- 刷新接口双写:数据同步写入 Redis 和 MySQL +- 启动时初始化多存储后端,检测可用性 + +**Non-Goals:** +- 不迁移用户认证、交易复盘等低频业务表(保持 SQLite) +- 不改造前端页面 +- 不统一 API 接口规范(留给后续 change) +- 不实现分布式部署或集群方案 + +## Decisions + +### 1. 存储分层策略 + +**决策**: 采用 Redis(缓存)+ MySQL(持久化)+ SQLite(兜底)三级架构。 + +**理由**: +- Redis 提供亚毫秒级读取性能,适合行情数据高频访问 +- MySQL 提供可靠的持久化能力和事务支持,替代 SQLite 成为主存储 +- SQLite 保留作为最终兜底,保证极端情况下系统仍可运行 +- 三级降级保证系统可用性,避免单点故障 + +**备选方案**: +- Redis + PostgreSQL: PostgreSQL 功能更强大,但部署复杂度更高,MySQL 更轻量 +- 纯 Redis: 无法满足持久化需求,数据丢失风险高 +- 纯 MySQL: 缺少缓存层,读取性能提升有限 + +### 2. 数据访问层设计 + +**决策**: 新增 `StorageManager` 抽象层,封装三级降级逻辑。 + +**理由**: +- 业务代码无需关心底层存储实现 +- 降级策略集中管理,便于维护和测试 +- 可通过配置切换存储后端,支持渐进式迁移 + +**备选方案**: +- 直接在业务代码中实现降级逻辑: 代码重复,难以维护 +- 使用 ORM 插件: 灵活性不足,难以实现复杂的降级策略 + +### 3. 双写一致性策略 + +**决策**: 刷新接口采用同步双写,先写 MySQL 再更新 Redis。 + +**理由**: +- MySQL 作为持久化主存储,必须先保证数据落盘 +- Redis 作为缓存,写入失败不影响数据持久化 +- 同步双写保证数据一致性,避免异步延迟导致的数据不一致 + +**备选方案**: +- 异步双写: 性能更好,但可能出现数据不一致 +- 仅写 MySQL,Redis 通过缓存未命中回填: 首次读取性能差 + +### 4. Redis 缓存策略 + +**决策**: 行情数据 TTL 30 天,缓存未命中时从 MySQL 读取并回填。 + +**理由**: +- 30 天覆盖大部分行情数据的活跃访问周期 +- 缓存未命中回填保证读取性能 +- TTL 过期后自动清理,避免内存无限增长 + +**备选方案**: +- 永不过期: 内存占用不可控 +- 更短 TTL(如 1 天): 缓存命中率低,频繁回源 MySQL + +### 5. 降级检测机制 + +**决策**: 启动时检测 Redis/MySQL 可用性,运行时捕获连接异常触发降级。 + +**理由**: +- 启动时检测可提前发现问题,输出明确的错误日志 +- 运行时降级保证系统可用性,避免服务中断 +- 降级状态可通过日志监控,便于运维排查 + +**备选方案**: +- 仅启动时检测: 无法处理运行中服务故障 +- 健康检查接口: 增加复杂度,当前场景不需要 + +### 6. 数据迁移策略 + +**决策**: 新增 MySQL 表结构,启动时从 SQLite 迁移历史数据到 MySQL。 + +**理由**: +- 保证历史数据不丢失 +- 一次性迁移,后续直接读写 MySQL +- SQLite 保留作为兜底,不删除 + +**备选方案**: +- 手动迁移: 容易遗漏,风险高 +- 双写过渡期: 复杂度高,当前场景不需要 + +## Risks / Trade-offs + +- **[风险]** Redis 和 MySQL 同时故障 → **[缓解]** SQLite 兜底,系统仍可运行 +- **[风险]** 双写一致性:MySQL 写入成功但 Redis 写入失败 → **[缓解]** Redis 写入失败不影响持久化,下次读取时从 MySQL 回填 +- **[风险]** Redis 内存占用过高 → **[缓解]** TTL 30 天自动清理,监控内存使用 +- **[风险]** MySQL 部署复杂度增加 → **[缓解]** 使用 docker-compose 一键部署 +- **[取舍]** 同步双写性能略低于异步双写 → 换取数据一致性保证 +- **[取舍]** 三级降级增加代码复杂度 → 换取系统高可用性 diff --git a/openspec/changes/storage-cache-refactor/proposal.md b/openspec/changes/storage-cache-refactor/proposal.md new file mode 100644 index 0000000..b0448c5 --- /dev/null +++ b/openspec/changes/storage-cache-refactor/proposal.md @@ -0,0 +1,33 @@ +## Why + +当前系统使用 SQLite 作为唯一存储后端,无法满足行情数据高频读写的性能需求。每次刷新行情数据都需要直接读写磁盘文件,导致响应延迟高、并发能力受限。需要引入更高效的存储方案以提升系统吞吐量和用户体验。 + +## What Changes + +- 引入 Redis 作为热数据缓存层,行情数据优先从 Redis 读取(TTL 30 天) +- 引入 MySQL 作为持久化存储层,替代 SQLite 承担主要持久化职责 +- 保留 SQLite 作为最终兜底方案,当 Redis 和 MySQL 均不可用时启用 +- 新增数据访问层:实现 Redis → MySQL → SQLite 的三级降级读取策略 +- 刷新接口改造:数据同步写入 Redis 和 MySQL,保证双写一致性 +- 启动流程改造:初始化 Redis/MySQL/SQLite 连接,检测可用性 + +## Capabilities + +### New Capabilities + +- `redis-cache-layer`: Redis 缓存读写能力,包括热数据缓存、TTL 管理、缓存失效策略 +- `mysql-persistence`: MySQL 持久化存储能力,替代 SQLite 承担主要持久化职责 +- `storage-fallback`: 三级降级策略(Redis → MySQL → SQLite),保证系统可用性 +- `dual-write-consistency`: 刷新接口双写能力,保证 Redis 和 MySQL 数据一致性 + +### Modified Capabilities + +(无既有 spec 需要修改) + +## Impact + +- **代码**: `app/database.py`、`app/models.py`、`app/api/data.py`、`app/services/collector.py`、`app/main.py` +- **依赖**: 新增 `redis`、`pymysql`(或 `aiomysql`)Python 包 +- **配置**: `app/config.py` 新增 Redis/MySQL 连接配置 +- **部署**: `docker-compose.yml` 新增 Redis 和 MySQL 服务 +- **数据库**: 新增 MySQL 数据库初始化脚本,保留 SQLite 作为兜底 diff --git a/openspec/changes/storage-cache-refactor/specs/dual-write-consistency/spec.md b/openspec/changes/storage-cache-refactor/specs/dual-write-consistency/spec.md new file mode 100644 index 0000000..da38826 --- /dev/null +++ b/openspec/changes/storage-cache-refactor/specs/dual-write-consistency/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: 刷新接口双写 +系统应当在刷新接口中实现 Redis 和 MySQL 的同步双写,保证数据一致性。 + +#### Scenario: 刷新接口双写成功 +- **WHEN** 用户调用刷新接口更新行情数据 +- **THEN** 系统先删除 Redis 缓存,再写入 MySQL,最后更新 Redis 缓存 + +#### Scenario: MySQL 写入失败 +- **WHEN** 刷新接口写入 MySQL 失败 +- **THEN** 系统返回错误响应,不更新 Redis 缓存,保证数据一致性 + +#### Scenario: Redis 写入失败 +- **WHEN** 刷新接口写入 MySQL 成功但 Redis 写入失败 +- **THEN** 系统返回成功响应(MySQL 数据已持久化),输出 Redis 写入失败警告日志 + +### Requirement: 双写顺序保证 +系统应当保证双写的顺序:先写 MySQL,再更新 Redis。 + +#### Scenario: 双写顺序 +- **WHEN** 刷新接口执行双写 +- **THEN** 系统先写入 MySQL 并确认成功,再更新 Redis 缓存 + +### Requirement: 缓存回填一致性 +系统应当在缓存未命中回填时保证回填数据与 MySQL 数据一致。 + +#### Scenario: 缓存未命中回填 +- **WHEN** Redis 缓存未命中且从 MySQL 读取数据 +- **THEN** 系统将 MySQL 数据回填到 Redis,TTL 设置为 30 天 diff --git a/openspec/changes/storage-cache-refactor/specs/mysql-persistence/spec.md b/openspec/changes/storage-cache-refactor/specs/mysql-persistence/spec.md new file mode 100644 index 0000000..e7e3f08 --- /dev/null +++ b/openspec/changes/storage-cache-refactor/specs/mysql-persistence/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: MySQL 连接管理 +系统应当提供 MySQL 连接管理能力,包括连接初始化、连接池管理和表结构初始化。 + +#### Scenario: MySQL 连接初始化成功 +- **WHEN** 应用启动且 MySQL 配置有效 +- **THEN** 系统成功建立 MySQL 连接并创建必要的表结构 + +#### Scenario: MySQL 连接初始化失败 +- **WHEN** 应用启动但 MySQL 服务不可用 +- **THEN** 系统输出错误日志并标记 MySQL 为不可用状态,但不阻止应用启动 + +### Requirement: 行情数据持久化存储 +系统应当使用 MySQL 作为行情数据的主要持久化存储,替代 SQLite。 + +#### Scenario: 行情数据写入 MySQL +- **WHEN** 刷新接口接收到行情数据更新请求 +- **THEN** 系统将数据写入 MySQL 的 `market_data` 表 + +#### Scenario: 行情数据从 MySQL 读取 +- **WHEN** Redis 缓存未命中且 MySQL 可用 +- **THEN** 系统从 MySQL 读取行情数据并返回 + +### Requirement: MySQL 表结构迁移 +系统应当提供从 SQLite 到 MySQL 的数据迁移能力,保证历史数据不丢失。 + +#### Scenario: 首次启动数据迁移 +- **WHEN** 应用首次启动且 MySQL 可用但表为空 +- **THEN** 系统从 SQLite 读取历史数据并迁移到 MySQL + +#### Scenario: 迁移完成 +- **WHEN** 数据迁移完成 +- **THEN** 系统输出迁移成功日志,后续读写直接操作 MySQL + +### Requirement: MySQL 事务支持 +系统应当使用 MySQL 事务保证数据写入的原子性和一致性。 + +#### Scenario: 批量写入事务 +- **WHEN** 刷新接口需要写入多个品种的行情数据 +- **THEN** 系统使用事务保证所有数据要么全部写入成功,要么全部回滚 diff --git a/openspec/changes/storage-cache-refactor/specs/redis-cache-layer/spec.md b/openspec/changes/storage-cache-refactor/specs/redis-cache-layer/spec.md new file mode 100644 index 0000000..4410e71 --- /dev/null +++ b/openspec/changes/storage-cache-refactor/specs/redis-cache-layer/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Redis 缓存连接管理 +系统应当提供 Redis 连接管理能力,包括连接初始化、健康检查和连接池管理。 + +#### Scenario: Redis 连接初始化成功 +- **WHEN** 应用启动且 Redis 配置有效 +- **THEN** 系统成功建立 Redis 连接并输出初始化成功日志 + +#### Scenario: Redis 连接初始化失败 +- **WHEN** 应用启动但 Redis 服务不可用 +- **THEN** 系统输出警告日志并标记 Redis 为不可用状态,但不阻止应用启动 + +### Requirement: 行情数据缓存读取 +系统应当优先从 Redis 读取行情数据,缓存未命中时回源 MySQL。 + +#### Scenario: Redis 缓存命中 +- **WHEN** 用户请求行情数据且 Redis 中存在该数据 +- **THEN** 系统直接从 Redis 返回数据,不访问 MySQL + +#### Scenario: Redis 缓存未命中 +- **WHEN** 用户请求行情数据但 Redis 中不存在该数据 +- **THEN** 系统从 MySQL 读取数据,回填到 Redis(TTL 30 天),并返回数据 + +### Requirement: Redis 缓存 TTL 管理 +系统应当为缓存数据设置 30 天过期时间,过期后自动清理。 + +#### Scenario: 缓存数据过期 +- **WHEN** 缓存数据超过 30 天未访问 +- **THEN** Redis 自动清理该数据,下次访问时从 MySQL 重新加载 + +#### Scenario: 缓存数据续期 +- **WHEN** 用户访问已缓存的行情数据 +- **THEN** 系统返回缓存数据,TTL 保持为 30 天(不续期) + +### Requirement: Redis 缓存失效 +系统应当在数据更新时主动失效相关缓存,保证数据一致性。 + +#### Scenario: 刷新接口触发缓存失效 +- **WHEN** 用户调用刷新接口更新行情数据 +- **THEN** 系统先删除 Redis 中对应的缓存数据,再执行双写流程 diff --git a/openspec/changes/storage-cache-refactor/specs/storage-fallback/spec.md b/openspec/changes/storage-cache-refactor/specs/storage-fallback/spec.md new file mode 100644 index 0000000..60f05ca --- /dev/null +++ b/openspec/changes/storage-cache-refactor/specs/storage-fallback/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: 三级降级策略 +系统应当实现 Redis → MySQL → SQLite 的三级降级读取策略,保证系统可用性。 + +#### Scenario: 正常模式(Redis + MySQL 可用) +- **WHEN** Redis 和 MySQL 均可用 +- **THEN** 系统优先从 Redis 读取,未命中时回源 MySQL + +#### Scenario: Redis 不可用降级 +- **WHEN** Redis 服务不可用但 MySQL 可用 +- **THEN** 系统直接从 MySQL 读取数据,跳过 Redis 缓存层 + +#### Scenario: Redis 和 MySQL 均不可用降级 +- **WHEN** Redis 和 MySQL 均不可用 +- **THEN** 系统降级到 SQLite,从 SQLite 读取数据并保证基础服务可用 + +### Requirement: 降级状态检测 +系统应当能够检测存储后端的可用性状态,并在降级时输出明确的日志。 + +#### Scenario: 启动时降级检测 +- **WHEN** 应用启动时检测到 Redis 或 MySQL 不可用 +- **THEN** 系统输出降级警告日志,说明当前使用的存储模式 + +#### Scenario: 运行时降级触发 +- **WHEN** 运行中 Redis 或 MySQL 连接异常 +- **THEN** 系统自动切换到降级模式,输出降级日志,并继续提供服务 + +### Requirement: 降级恢复 +系统应当能够在存储后端恢复后自动恢复到正常模式。 + +#### Scenario: Redis 恢复 +- **WHEN** Redis 从不可用状态恢复 +- **THEN** 系统自动恢复到 Redis + MySQL 模式,后续读取优先使用 Redis + +#### Scenario: MySQL 恢复 +- **WHEN** MySQL 从不可用状态恢复 +- **THEN** 系统自动恢复到 Redis + MySQL 模式,SQLite 降级结束 diff --git a/openspec/changes/storage-cache-refactor/tasks.md b/openspec/changes/storage-cache-refactor/tasks.md new file mode 100644 index 0000000..aa6185f --- /dev/null +++ b/openspec/changes/storage-cache-refactor/tasks.md @@ -0,0 +1,46 @@ +## 1. 依赖与配置 + +- [ ] 1.1 添加 `redis`、`pymysql` 依赖到 `requirements.txt` +- [ ] 1.2 在 `app/config.py` 中新增 Redis 和 MySQL 连接配置项(host、port、user、password、database) +- [ ] 1.3 在 `docker-compose.yml` 中新增 Redis 和 MySQL 服务定义 + +## 2. 数据库模型与初始化 + +- [ ] 2.1 创建 `app/mysql_database.py`,实现 MySQL 连接引擎和 SessionLocal +- [ ] 2.2 创建 `app/redis_client.py`,实现 Redis 连接池和客户端封装 +- [ ] 2.3 在 `app/main.py` 的 `lifespan` 中初始化 Redis 和 MySQL 连接,检测可用性 +- [ ] 2.4 创建 MySQL 表结构初始化脚本(复用现有 ORM 模型) + +## 3. 数据迁移 + +- [ ] 3.1 创建数据迁移脚本,从 SQLite 读取历史数据并写入 MySQL +- [ ] 3.2 在应用启动时检测 MySQL 表是否为空,自动触发迁移 + +## 4. 存储管理层 + +- [ ] 4.1 创建 `app/storage_manager.py`,实现 `StorageManager` 抽象层 +- [ ] 4.2 实现 Redis 缓存读取逻辑(命中返回,未命中回源 MySQL 并回填) +- [ ] 4.3 实现 MySQL 持久化读写逻辑 +- [ ] 4.4 实现三级降级策略(Redis → MySQL → SQLite) +- [ ] 4.5 实现降级状态检测和恢复机制 + +## 5. 双写与缓存一致性 + +- [ ] 5.1 实现刷新接口双写逻辑:先删 Redis 缓存,再写 MySQL,最后更新 Redis +- [ ] 5.2 实现双写顺序保证和错误处理(MySQL 失败回滚,Redis 失败记录日志) +- [ ] 5.3 实现缓存未命中回填逻辑(TTL 30 天) + +## 6. 接口改造 + +- [ ] 6.1 改造 `app/api/data.py` 中的行情数据读取接口,使用 `StorageManager` +- [ ] 6.2 改造 `app/api/data.py` 中的刷新接口,使用双写逻辑 +- [ ] 6.3 改造 `app/services/collector.py`,使用 `StorageManager` 写入数据 + +## 7. 测试与验证 + +- [ ] 7.1 验证 Redis 缓存命中场景 +- [ ] 7.2 验证 Redis 缓存未命中回源 MySQL 场景 +- [ ] 7.3 验证 Redis 不可用降级到 MySQL 场景 +- [ ] 7.4 验证 Redis 和 MySQL 均不可用降级到 SQLite 场景 +- [ ] 7.5 验证刷新接口双写一致性 +- [ ] 7.6 验证数据迁移完整性