This commit is contained in:
2026-09-07 13:34:26 +08:00
parent ad9245abdd
commit 359f9ae2e4
23 changed files with 2260 additions and 513 deletions

View File

@@ -218,9 +218,16 @@ async def _replace_day(session: AsyncSession, model, rows: list[dict], d_str: st
async def _existing_candle_dates(session: AsyncSession) -> set[str]:
"""candles 表已落库的交易日集合YYYYMMDD 字符串,便于比对)。"""
"""candles 表已落库的交易日集合YYYYMMDD 字符串,便于比对)。
限定在市股票符号candles 底座同时容纳 ETFetf_sync 写入),若不隔离,
只同步了 ETF 的交易日会被误判为「股票日线已完成」而跳过当日股票同步。
"""
res = await session.execute(
select(func.distinct(func.date(Candle.ts))).where(Candle.timeframe == "1d")
select(func.distinct(func.date(Candle.ts))).where(
Candle.timeframe == "1d",
Candle.symbol.in_(select(StockBasic.symbol).where(StockBasic.list_status == "L")),
)
)
return {r[0].strftime("%Y%m%d") for r in res if r[0] is not None}
@@ -339,12 +346,12 @@ async def _run_sync(days: int, force: bool) -> None:
# candles/复权因子已更新:作废旧 K 线预览缓存(键含版本号,自增即全体失效)
await cache.bump_version("candles")
# 预热同步状态缓存:同步任务自己付一次重聚合(>10s,轮询方毫秒级拿到新数字
# 预热统计缓存:同步任务自己付一次重聚合(>10s。SWR 下轮询方不等待——
# 先拿到旧值last_trade_date 本就实时),重算完成后数字自然换新
_sync_state["step"] = "正在更新统计缓存"
try:
async with async_session() as s2:
await _db_stats(s2)
except Exception: # noqa: BLE001 —— 预热失败只影响下一次轮询的时延
await _refresh_stats(await cache.get_version("candles"))
except Exception: # noqa: BLE001 —— 预热失败只影响统计数字的新鲜度
pass
_sync_state["step"] = "同步完成"
except Exception as e: # noqa: BLE001
@@ -369,12 +376,26 @@ async def start_sync(session: AsyncSession, days: int, force: bool) -> dict:
return dict(_sync_state)
# candles 是千万行表,重聚合(全表 count / distinct 日期)在远程库实测 >11s
# 结果按 ver:candles 版本号缓存(同步完成 bump 失效),前端 2s 轮询只付毫秒级。
# candles 是千万行表,重聚合(全表 count / distinct 日期)在远程库实测 >11s
# 读路径 SWR版本失效(同步完成 bump/进程重启后,先吐最近一次旧值(进程内 →
# Redis 无版本 last 键),后台单飞重算——轮询请求**永不等待**重聚合(旧设计里
# 轮询会在 _stats_lock 上排队 >10s表现为首页「数据更新至」加载不出来
# 只有史上第一次(进程内与 Redis 都无记录)才现场算。
# last_dailymax(ts),走索引很快)保持每次实时——它是 UI 主展示字段。
_status_stats_cache: dict = {"at": 0.0, "ver": -1, "data": None}
_STATS_TTL = 120.0 # 进程内兜底 TTLRedis 不可用时重聚合的最小间隔)
_stats_bg_tasks: set[asyncio.Task] = set() # 后台写缓存的引用,防 GC
_stats_lock = asyncio.Lock() # 单飞锁:同步尾部的预热与轮询并发时,重聚合只跑一次
_stats_bg_tasks: set[asyncio.Task] = set() # 后台任务引用,防 GC
_stats_lock = asyncio.Lock() # 单飞锁:同一时刻至多一个重聚合在跑
_STATS_LAST_KEY = "syncstats:last" # 无版本号的最近一次结果(跨版本/跨重启兜底)
def _fresh_local(ver: int) -> dict | None:
d = _status_stats_cache["data"]
if d is not None and _status_stats_cache["ver"] == ver \
and time.time() - _status_stats_cache["at"] < _STATS_TTL:
return d
return None
async def _heavy_stats(session: AsyncSession) -> dict:
@@ -391,30 +412,52 @@ async def _heavy_stats(session: AsyncSession) -> dict:
}
async def _store_stats(ver: int, data: dict) -> None:
_status_stats_cache.update(at=time.time(), ver=ver, data=data)
# 写 Redis 后台执行,失败由 cache 层静默降级,不拖慢调用方
tasks = [
asyncio.create_task(cache.cache_set(
f"syncstats:v{ver}", data, ttl=settings.sync_stats_redis_ttl)),
asyncio.create_task(cache.cache_set(
_STATS_LAST_KEY, data, ttl=settings.sync_stats_redis_ttl)),
]
_stats_bg_tasks.update(tasks)
for t in tasks:
t.add_done_callback(_stats_bg_tasks.discard)
async def _refresh_stats(ver: int) -> None:
"""后台重算(单飞):等锁双检后重聚合并写缓存。已有重算在跑则直接返回。"""
from ..db import async_session # 延迟导入避免循环
if _stats_lock.locked():
return
async with _stats_lock:
if _fresh_local(ver) is not None:
return # 等锁期间已被并发填充
async with async_session() as s:
data = await _heavy_stats(s)
await _store_stats(ver, data)
async def _db_stats(session: AsyncSession) -> dict:
"""candles/快照/股票列表实况:重聚合走「版本化 Redis + 进程内」双层缓存,
last_dailymax(ts),走索引很快)保持每次实时——它是 UI 主展示字段。"""
def _fresh_local(ver: int) -> dict | None:
d = _status_stats_cache["data"]
if d is not None and _status_stats_cache["ver"] == ver \
and time.time() - _status_stats_cache["at"] < _STATS_TTL:
return d
return None
ver = await cache.get_version("candles")
heavy = _fresh_local(ver)
if heavy is None:
async with _stats_lock: # 双检:等锁期间可能已被并发请求/同步预热填充
heavy = _fresh_local(ver) or await cache.cache_get(f"syncstats:v{ver}")
if heavy is None:
heavy = await _heavy_stats(session)
# 写 Redis 后台执行,失败由 cache 层静默降级,不拖慢本次返回
task = asyncio.create_task(
cache.cache_set(f"syncstats:v{ver}", heavy, ttl=settings.sync_stats_redis_ttl))
_stats_bg_tasks.add(task)
task.add_done_callback(_stats_bg_tasks.discard)
_status_stats_cache.update(at=time.time(), ver=ver, data=heavy)
stale = _status_stats_cache["data"] or await cache.cache_get(_STATS_LAST_KEY)
if stale is not None:
# SWR先返旧值行数等数字仅展示用旧几秒无害后台重算
heavy = stale
t = asyncio.create_task(_refresh_stats(ver))
_stats_bg_tasks.add(t)
t.add_done_callback(_stats_bg_tasks.discard)
else:
# 史上第一次(进程内与 Redis 均无记录):只能现场算,锁内单飞
async with _stats_lock:
heavy = _fresh_local(ver) or await cache.cache_get(f"syncstats:v{ver}")
if heavy is None:
heavy = await _heavy_stats(session)
await _store_stats(ver, heavy)
last_daily = await session.scalar(
select(func.max(Candle.ts)).where(Candle.timeframe == "1d")
)