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/app/storage_manager.py

775 lines
27 KiB

import datetime as _datetime
import json
import logging
import time
from datetime import datetime
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
from app.config import CACHE_TTL_SECONDS, REDIS_TTL_SECONDS
from app.models import MarketData, SymbolTimestamp
logger = logging.getLogger(__name__)
# Redis 缓存 TTL读取自 app.config.REDIS_TTL_SECONDS默认 30 天)
# 业务缓存有效期:读取自 app.config.CACHE_TTL_SECONDS默认 300 秒)
class StorageManager:
"""存储管理器骨架,封装 Redis / MySQL 可用性检测与三级降级状态。"""
def __init__(self, redis_client=None, mysql_engine=None, check_interval=30):
self.redis_client = redis_client
self.mysql_engine = mysql_engine
self.check_interval = check_interval
self.redis_available = False
self.mysql_available = False
self.last_redis_check = 0
self.last_mysql_check = 0
def check_redis(self):
"""惰性检测 Redis 可用性30 秒间隔内返回缓存结果。"""
now = time.time()
if now - self.last_redis_check < self.check_interval:
return self.redis_available
if self.redis_client is None:
self.redis_available = False
else:
try:
self.redis_client.ping()
self.redis_available = True
except Exception as e:
self.redis_available = False
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
if self.mysql_engine is None:
self.mysql_available = False
else:
try:
with self.mysql_engine.connect() as conn:
conn.execute(text("SELECT 1"))
self.mysql_available = True
except Exception as e:
self.mysql_available = False
self.last_mysql_check = now
return self.mysql_available
def _parse_fetched_at(self, timestamp_raw):
"""优先使用输入 timestamp解析失败时回退到当前时间。"""
if not timestamp_raw:
return _datetime.datetime.now()
if isinstance(timestamp_raw, str):
try:
return _datetime.datetime.fromisoformat(timestamp_raw)
except Exception:
return _datetime.datetime.now()
if hasattr(timestamp_raw, "isoformat"):
return timestamp_raw
return _datetime.datetime.now()
def is_available(self):
"""判断 Redis 或 MySQL 至少一个可用。"""
return self.check_redis() or self.check_mysql()
def initialize(self, redis_client=None, mysql_engine=None):
"""启动时调用,可选注入 Redis/MySQL 客户端并检测各后端可用性。"""
if redis_client is not None:
self.redis_client = redis_client
if mysql_engine is not None:
self.mysql_engine = mysql_engine
# 重置检测缓存,确保注入新客户端后重新探测后端可用性
self.last_redis_check = 0
self.last_mysql_check = 0
redis_ok = self.check_redis()
mysql_ok = self.check_mysql()
if redis_ok:
logger.info("Redis 可用")
else:
logger.warning("Redis 不可用")
if mysql_ok:
logger.info("MySQL 可用")
else:
logger.warning("MySQL 不可用")
def _discover_periods(self, symbol, data_type):
"""从 Redis 或 MySQL 发现该品种已缓存的所有周期。"""
if self.check_redis():
try:
pattern = f"market_data:{symbol}:*"
keys = self.redis_client.keys(pattern)
periods = set()
for key in keys:
key_str = key.decode("utf-8") if isinstance(key, bytes) else key
parts = key_str.split(":")
if len(parts) == 3:
periods.add(parts[2])
if periods:
return sorted(periods)
except Exception as e:
logger.warning(f"Redis 发现周期失败 [{symbol}]: {e}")
if not self.check_mysql():
return []
try:
SessionLocal = sessionmaker(bind=self.mysql_engine)
with SessionLocal() as db:
rows = (
db.query(MarketData.period)
.filter_by(symbol=symbol, data_type=data_type)
.distinct()
.all()
)
return sorted([r.period for r in rows if r.period])
except Exception as e:
logger.warning(f"MySQL 发现周期失败 [{symbol}]: {e}")
return []
def _get_market_data_single_period(self, symbol, data_type, period):
"""读取单个周期的行情数据Redis → MySQL → None。"""
cache_key = f"market_data:{symbol}:{period}"
# 1. 检查 Redis 缓存
if self.check_redis():
try:
raw = self.redis_client.get(cache_key)
if raw:
return json.loads(raw)
except Exception as e:
logger.warning(f"Redis 读取失败 [{cache_key}]: {e}")
# 2. Redis 未命中,检查 MySQL 可用性
if not self.check_mysql():
return None
# 3. 从 MySQL 读取
try:
SessionLocal = sessionmaker(bind=self.mysql_engine)
with SessionLocal() as db:
record = (
db.query(MarketData)
.filter_by(symbol=symbol, data_type=data_type, period=period)
.first()
)
if not record:
return None
result = {
"current_price": record.current_price,
"timestamp": (
record.fetched_at.isoformat() if record.fetched_at else None
),
"candles": (
json.loads(record.candles_json) if record.candles_json else []
),
}
# 4. 回填 RedisTTL 30 天)
if self.check_redis():
try:
self.redis_client.setex(
cache_key,
REDIS_TTL_SECONDS,
json.dumps(result, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"Redis 回填失败 [{cache_key}]: {e}")
return result
except Exception as e:
logger.warning(f"MySQL 读取失败 [{cache_key}]: {e}")
return None
@staticmethod
def _parse_candle_time(candle):
"""解析单根 K 线的时间字段,返回 naive datetime 或 None。"""
candle_time = candle.get("datetime") or candle.get("time")
if not candle_time:
return None
if isinstance(candle_time, str):
try:
dt = datetime.fromisoformat(candle_time.replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.replace(tzinfo=None)
return dt
except Exception:
return None
if hasattr(candle_time, "tzinfo") and candle_time.tzinfo is not None:
return candle_time.replace(tzinfo=None)
return candle_time
def _filter_and_truncate(self, merged, end_time, max_candles):
"""在合并结果上按 end_time 与 max_candles 过滤/截断每个周期。"""
if merged is None:
return None
filter_end_time = end_time if end_time else datetime.now()
if filter_end_time.tzinfo is not None:
filter_end_time = filter_end_time.replace(tzinfo=None)
timeframes = {}
for period, candles in merged.get("timeframes", {}).items():
filtered = []
for candle in candles:
candle_dt = self._parse_candle_time(candle)
if candle_dt is None or candle_dt <= filter_end_time:
filtered.append(candle)
if len(filtered) > max_candles:
filtered = filtered[-max_candles:]
timeframes[period] = filtered
merged["timeframes"] = timeframes
return merged
def _merge_period_results(self, symbol, data_type, period_results):
"""将按周期返回的结果合并为统一格式。"""
timeframes = {}
current_price = None
newest = None
for period, result in period_results.items():
if not result:
continue
candles = result.get("candles", [])
timeframes[period] = candles
if current_price is None:
current_price = result.get("current_price")
timestamp_raw = result.get("timestamp")
if timestamp_raw:
try:
timestamp_dt = datetime.fromisoformat(timestamp_raw)
# 去掉时区信息,统一使用 naive datetime
if timestamp_dt.tzinfo is not None:
timestamp_dt = timestamp_dt.replace(tzinfo=None)
if newest is None or timestamp_dt > newest:
newest = timestamp_dt
except Exception:
pass
if not timeframes:
return None
if newest is None:
newest = datetime.now()
now = datetime.now()
is_fresh = (now - newest).total_seconds() < CACHE_TTL_SECONDS
return {
"symbol": symbol,
"type": data_type,
"current_price": current_price,
"timestamp": newest.isoformat(),
"timeframes": timeframes,
"is_fresh": is_fresh,
"fetched_at": newest.isoformat(),
}
def get_market_data(self, symbol, data_type, periods=None, end_time=None, max_candles=100):
"""
读取行情数据Redis MySQL None由调用方降级到 SQLite
Args:
symbol: 品种合约代码
data_type: 数据类型 futures
periods: 周期或周期列表 "5min" ["5min", "15min"];
None 时自动发现全部周期
end_time: 结束时间可选过滤该时间之前的 K 线
max_candles: 每个周期最大 K 线数量默认 100
Returns:
periods 为字符串时返回旧版单周期 dict;
periods 为列表/None 时返回合并后的 dict包含 timeframes None
"""
single_period = isinstance(periods, str)
query_periods = [periods] if single_period else periods
if query_periods is None:
query_periods = self._discover_periods(symbol, data_type)
if not query_periods:
return None
period_results = {}
for period in query_periods:
period_results[period] = self._get_market_data_single_period(
symbol, data_type, period
)
merged = self._merge_period_results(symbol, data_type, period_results)
if merged is None:
return None
merged = self._filter_and_truncate(merged, end_time, max_candles)
if single_period:
period = query_periods[0]
return {
"current_price": merged.get("current_price"),
"timestamp": merged.get("timestamp"),
"candles": merged["timeframes"].get(period, []),
}
return merged
def get_symbol_timestamp(self, symbol, data_type):
"""
读取合约时间戳Redis MySQL None由调用方降级到 SQLite
Args:
symbol: 品种合约代码
data_type: 数据类型 futures
Returns:
dict None
"""
cache_key = f"symbol_timestamps:{symbol}"
# 1. 检查 Redis 缓存
if self.check_redis():
try:
raw = self.redis_client.get(cache_key)
if raw:
return json.loads(raw)
except Exception as e:
logger.warning(f"Redis 读取失败 [{cache_key}]: {e}")
# 2. Redis 未命中,检查 MySQL 可用性
if not self.check_mysql():
return None
# 3. 从 MySQL 读取
try:
SessionLocal = sessionmaker(bind=self.mysql_engine)
with SessionLocal() as db:
record = (
db.query(SymbolTimestamp)
.filter_by(symbol=symbol, data_type=data_type)
.first()
)
if not record:
return None
result = {
"last_refresh_at": (
record.last_refresh_at.isoformat()
if record.last_refresh_at
else None
),
"refresh_count": record.refresh_count,
}
# 4. 回填 RedisTTL 30 天)
if self.check_redis():
try:
self.redis_client.setex(
cache_key,
REDIS_TTL_SECONDS,
json.dumps(result, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"Redis 回填失败 [{cache_key}]: {e}")
return result
except Exception as e:
logger.warning(f"MySQL 读取失败 [{cache_key}]: {e}")
return None
def save_market_data(self, symbol, data):
"""
保存行情数据先删 Redis 缓存再写 MySQLMySQL 成功后回填 Redis
Args:
symbol: 品种合约代码
data: 采集脚本返回的数据包含 timeframes, current_price, timestamp, type
Returns:
bool: MySQL 写入是否成功
"""
data_type = data.get("type", "futures")
timeframes = data.get("timeframes", {})
current_price = data.get("current_price")
fetched_at = self._parse_fetched_at(data.get("timestamp"))
cache_keys = [f"market_data:{symbol}:{period}" for period in timeframes.keys()]
# 1. 删除 Redis 缓存
if self.check_redis() and cache_keys:
try:
self.redis_client.delete(*cache_keys)
except Exception as e:
logger.warning(f"Redis 删除缓存失败 [{symbol}]: {e}")
# 2. 写入 MySQL事务
if not self.check_mysql():
logger.warning(f"MySQL 不可用,无法写入行情数据 [{symbol}]")
return False
try:
SessionLocal = sessionmaker(bind=self.mysql_engine)
with SessionLocal() as db:
try:
for period, candles in timeframes.items():
candles_json = json.dumps(candles, ensure_ascii=False)
record = (
db.query(MarketData)
.filter_by(symbol=symbol, data_type=data_type, period=period)
.first()
)
if record:
record.candles_json = candles_json
record.current_price = current_price
record.fetched_at = fetched_at
record.candle_count = len(candles)
else:
record = MarketData(
symbol=symbol,
data_type=data_type,
period=period,
candles_json=candles_json,
current_price=current_price,
fetched_at=fetched_at,
candle_count=len(candles),
)
db.add(record)
db.commit()
except Exception:
db.rollback()
raise
except Exception as e:
logger.error(f"MySQL 写入行情数据失败 [{symbol}]: {e}")
return False
# 3. MySQL 成功,回填 Redis失败仅记录日志
if self.check_redis():
for period, candles in timeframes.items():
cache_key = f"market_data:{symbol}:{period}"
cache_value = {
"current_price": current_price,
"timestamp": fetched_at.isoformat(),
"candles": candles,
}
try:
self.redis_client.setex(
cache_key,
REDIS_TTL_SECONDS,
json.dumps(cache_value, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"Redis 更新失败 [{cache_key}]: {e}")
return True
def save_symbol_timestamp(self, symbol, data_type, refresh_time):
"""
保存合约时间戳先删 Redis 缓存再写 MySQLMySQL 成功后回填 Redis
Args:
symbol: 品种合约代码
data_type: 数据类型 futures
refresh_time: 刷新时间datetime
Returns:
bool: MySQL 写入是否成功
"""
cache_key = f"symbol_timestamps:{symbol}"
# 1. 删除 Redis 缓存
if self.check_redis():
try:
self.redis_client.delete(cache_key)
except Exception as e:
logger.warning(f"Redis 删除缓存失败 [{cache_key}]: {e}")
# 2. 写入 MySQL事务
if not self.check_mysql():
logger.warning(f"MySQL 不可用,无法写入合约时间戳 [{symbol}]")
return False
try:
SessionLocal = sessionmaker(bind=self.mysql_engine)
with SessionLocal() as db:
try:
record = (
db.query(SymbolTimestamp)
.filter_by(symbol=symbol, data_type=data_type)
.first()
)
if record:
record.last_refresh_at = refresh_time
record.refresh_count += 1
else:
record = SymbolTimestamp(
symbol=symbol,
data_type=data_type,
last_refresh_at=refresh_time,
refresh_count=1,
)
db.add(record)
db.commit()
except Exception:
db.rollback()
raise
except Exception as e:
logger.error(f"MySQL 写入合约时间戳失败 [{symbol}]: {e}")
return False
# 3. MySQL 成功,回填 Redis失败仅记录日志
if self.check_redis():
cache_value = {
"last_refresh_at": refresh_time.isoformat(),
"refresh_count": record.refresh_count,
}
try:
self.redis_client.setex(
cache_key,
REDIS_TTL_SECONDS,
json.dumps(cache_value, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"Redis 更新失败 [{cache_key}]: {e}")
return True
def save_market_data_with_timestamp(self, symbol, data, refresh_time):
"""
在同一 MySQL 事务中原子写入行情数据与合约时间戳
Redis 缓存的删除/回填在事务外进行失败仅记录日志不影响 MySQL 写入
Args:
symbol: 品种合约代码
data: 采集脚本返回的数据包含 timeframes, current_price, timestamp, type
refresh_time: 刷新时间datetime
Returns:
bool: MySQL 写入是否成功
"""
data_type = data.get("type", "futures")
timeframes = data.get("timeframes", {})
current_price = data.get("current_price")
fetched_at = self._parse_fetched_at(data.get("timestamp"))
market_cache_keys = [
f"market_data:{symbol}:{period}" for period in timeframes.keys()
]
timestamp_cache_key = f"symbol_timestamps:{symbol}"
cache_keys = market_cache_keys + [timestamp_cache_key]
# 1. 删除 Redis 缓存
if self.check_redis() and cache_keys:
try:
self.redis_client.delete(*cache_keys)
except Exception as e:
logger.warning(f"Redis 删除缓存失败 [{symbol}]: {e}")
# 2. 写入 MySQL同一事务
if not self.check_mysql():
logger.warning(f"MySQL 不可用,无法写入行情数据与时间戳 [{symbol}]")
return False
try:
SessionLocal = sessionmaker(bind=self.mysql_engine)
with SessionLocal() as db:
try:
for period, candles in timeframes.items():
candles_json = json.dumps(candles, ensure_ascii=False)
record = (
db.query(MarketData)
.filter_by(symbol=symbol, data_type=data_type, period=period)
.first()
)
if record:
record.candles_json = candles_json
record.current_price = current_price
record.fetched_at = fetched_at
record.candle_count = len(candles)
else:
record = MarketData(
symbol=symbol,
data_type=data_type,
period=period,
candles_json=candles_json,
current_price=current_price,
fetched_at=fetched_at,
candle_count=len(candles),
)
db.add(record)
timestamp_record = (
db.query(SymbolTimestamp)
.filter_by(symbol=symbol, data_type=data_type)
.first()
)
if timestamp_record:
timestamp_record.last_refresh_at = refresh_time
timestamp_record.refresh_count += 1
refresh_count = timestamp_record.refresh_count
else:
timestamp_record = SymbolTimestamp(
symbol=symbol,
data_type=data_type,
last_refresh_at=refresh_time,
refresh_count=1,
)
db.add(timestamp_record)
refresh_count = 1
db.commit()
except Exception:
db.rollback()
raise
except Exception as e:
logger.error(f"MySQL 写入行情数据与时间戳失败 [{symbol}]: {e}")
return False
# 3. MySQL 成功,回填 Redis失败仅记录日志
if self.check_redis():
for period, candles in timeframes.items():
cache_key = f"market_data:{symbol}:{period}"
cache_value = {
"current_price": current_price,
"timestamp": fetched_at.isoformat(),
"candles": candles,
}
try:
self.redis_client.setex(
cache_key,
REDIS_TTL_SECONDS,
json.dumps(cache_value, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"Redis 更新失败 [{cache_key}]: {e}")
timestamp_cache_value = {
"last_refresh_at": refresh_time.isoformat(),
"refresh_count": refresh_count,
}
try:
self.redis_client.setex(
timestamp_cache_key,
REDIS_TTL_SECONDS,
json.dumps(timestamp_cache_value, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"Redis 更新失败 [{timestamp_cache_key}]: {e}")
return True
def delete_cache(self, symbol, periods):
"""
批量删除 Redis 行情数据缓存键
Args:
symbol: 品种合约代码
periods: 周期列表 ["5min", "15min"]
"""
if not self.check_redis():
return
cache_keys = [f"market_data:{symbol}:{period}" for period in periods]
if not cache_keys:
return
try:
self.redis_client.delete(*cache_keys)
except Exception as e:
logger.warning(f"Redis 批量删除缓存失败 [{symbol}]: {e}")
def cache_get(self, key):
"""
通用 Redis 缓存读取
Args:
key: 缓存键
Returns:
反序列化后的值 None
"""
if not self.check_redis():
return None
try:
raw = self.redis_client.get(key)
if raw:
return json.loads(raw)
except Exception as e:
logger.warning(f"Redis 读取失败 [{key}]: {e}")
return None
def cache_set(self, key, value, ttl=None):
"""
通用 Redis 缓存写入
Args:
key: 缓存键
value: 缓存值 JSON 序列化
ttl: 过期时间默认 REDIS_TTL_SECONDS
Returns:
bool: 是否成功
"""
if not self.check_redis():
return False
if ttl is None:
ttl = REDIS_TTL_SECONDS
try:
self.redis_client.setex(
key, ttl, json.dumps(value, ensure_ascii=False, default=str)
)
return True
except Exception as e:
logger.warning(f"Redis 写入失败 [{key}]: {e}")
return False
def cache_delete(self, key):
"""
通用 Redis 缓存删除
Args:
key: 缓存键
"""
if not self.check_redis():
return
try:
self.redis_client.delete(key)
except Exception as e:
logger.warning(f"Redis 删除失败 [{key}]: {e}")
_storage_manager = None
def get_storage_manager():
"""获取全局 StorageManager 单例。"""
global _storage_manager
if _storage_manager is None:
_storage_manager = StorageManager()
return _storage_manager