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

417 lines
14 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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.models import MarketData, SymbolTimestamp
logger = logging.getLogger(__name__)
# Redis 缓存 TTL30 天
CACHE_TTL_SECONDS = 30 * 24 * 60 * 60
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_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 get_market_data(self, symbol, data_type, periods):
"""
读取行情数据Redis → MySQL → None由调用方降级到 SQLite
Args:
symbol: 品种合约代码
data_type: 数据类型,如 futures
periods: 周期,如 5min对应 Redis key 中的 period
Returns:
dict 或 None
"""
cache_key = f"market_data:{symbol}:{periods}"
# 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=periods)
.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,
CACHE_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 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,
CACHE_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,
CACHE_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,
CACHE_TTL_SECONDS,
json.dumps(cache_value, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"Redis 更新失败 [{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}")
_storage_manager = None
def get_storage_manager():
"""获取全局 StorageManager 单例。"""
global _storage_manager
if _storage_manager is None:
_storage_manager = StorageManager()
return _storage_manager