选股计划缓存:存活时间盖住整个交易日,同键只算一次(09-10 页面超时修复)

09-10 开盘前页面报「上游的选股计划读不到 · timeout of 60000ms exceeded」。根因两半:

一、缓存只活 10 分钟。PMS 在 08:40 预热了一次(那次花 39 秒),08:50 就过期;用户
    09:02 打开页面正好撞上冷启动。改成 12 小时——缓存键里带的是真实数据日,上游换日
    后旧那份立刻失效,本来就不靠时间保新鲜;12 小时是为了让 08:40 那次预热管到收盘。

二、装配在锁外面,几路并发各跑一遍。页面一次加载拉多路,全部判未命中、各自跑一趟
    三十多秒的装配、还互相抢同一批库连接,实际耗时远超单跑一次——这才是破 60 秒的
    那一半。改成同键单飞:第一个去算,后到的等它算完直接吃缓存;不同参数组合互不阻塞。

新增 test_plan_cache.py 钉住四件事:换日立刻失效、同键只算一次、不同参数不互相阻塞、
存活时间盖得住交易日。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-10 09:09:40 +08:00
parent f4f9b4ac80
commit 47369d3e58
2 changed files with 194 additions and 9 deletions

43
api.py
View File

@ -71,8 +71,15 @@ def plan_dates(limit: int = 30):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_PLAN_CACHE: dict = {} _PLAN_CACHE: dict = {}
_PLAN_CACHE_LOCK = threading.Lock() _PLAN_CACHE_LOCK = threading.Lock()
PLAN_CACHE_SEC = int(os.environ.get("PLAN_CACHE_SEC", "600")) # 2026-09-10: 默认从 600 秒10 分钟)改成 12 小时。
# 起因: 09-10 开盘前 PMS 在 08:40 预热了一次(花 39 秒),缓存 08:50 就过期;用户 09:02
# 打开页面正好撞上冷启动,页面几路并发一起等,叠加破了 60 秒超时。
# 为什么可以放这么长: 缓存键里带的是**真实数据日**,上游换日后旧那份立刻失效,不靠时间
# 保新鲜。12 小时是为了覆盖整个交易日08:40 预热能管到 20:40不是为了省算力。
# 当天要强刷仍然走 nocache 参数。
PLAN_CACHE_SEC = int(os.environ.get("PLAN_CACHE_SEC", "43200"))
_PLAN_CACHE_MAX = 8 # 不同参数组合最多留几份,防内存慢涨 _PLAN_CACHE_MAX = 8 # 不同参数组合最多留几份,防内存慢涨
_PLAN_BUILD_LOCKS: dict = {} # 每个缓存键一把「正在算」的锁,见 _plan_cached 里的说明
def _plan_cached(date, top, obs_top, theme_cap, nocache: bool): def _plan_cached(date, top, obs_top, theme_cap, nocache: bool):
@ -93,14 +100,32 @@ def _plan_cached(date, top, obs_top, theme_cap, nocache: bool):
hit = _PLAN_CACHE.get(key) hit = _PLAN_CACHE.get(key)
if hit and now - hit[0] < PLAN_CACHE_SEC: if hit and now - hit[0] < PLAN_CACHE_SEC:
return hit[1], True return hit[1], True
data = plan.collect(date, top, obs_top, theme_cap)
data.pop("_full", None) # 同一个键同时只算一次 (2026-09-10)。原来 plan.collect 在锁外面, 缓存冷的时候页面
if PLAN_CACHE_SEC > 0: # 几路并发进来会**各跑一遍**这趟三十多秒的装配, 还互相抢同一批库连接, 实际耗时远超
with _PLAN_CACHE_LOCK: # 单跑一次 —— 这正是 09-10 早上页面破 60 秒超时的那一半原因。
_PLAN_CACHE[key] = (now, data) # 现在让第一个请求去算, 后到的等它算完直接吃缓存。等待用的是每个键自己的锁,
if len(_PLAN_CACHE) > _PLAN_CACHE_MAX: # 不同参数组合互不阻塞。
for k in sorted(_PLAN_CACHE, key=lambda x: _PLAN_CACHE[x][0])[:-_PLAN_CACHE_MAX]: with _PLAN_CACHE_LOCK:
_PLAN_CACHE.pop(k, None) lock = _PLAN_BUILD_LOCKS.get(key)
if lock is None:
lock = _PLAN_BUILD_LOCKS[key] = threading.Lock()
with lock:
# 双重检查: 排在后面的请求进到这里时, 前一个多半已经把结果写进缓存了
if not nocache and PLAN_CACHE_SEC > 0:
with _PLAN_CACHE_LOCK:
hit = _PLAN_CACHE.get(key)
if hit and time.time() - hit[0] < PLAN_CACHE_SEC:
return hit[1], True
data = plan.collect(date, top, obs_top, theme_cap)
data.pop("_full", None)
if PLAN_CACHE_SEC > 0:
with _PLAN_CACHE_LOCK:
_PLAN_CACHE[key] = (time.time(), data)
if len(_PLAN_CACHE) > _PLAN_CACHE_MAX:
for k in sorted(_PLAN_CACHE, key=lambda x: _PLAN_CACHE[x][0])[:-_PLAN_CACHE_MAX]:
_PLAN_CACHE.pop(k, None)
_PLAN_BUILD_LOCKS.pop(k, None)
return data, False return data, False

160
test_plan_cache.py Normal file
View File

@ -0,0 +1,160 @@
"""选股计划的接口缓存2026-09-10 故障修复):数据日换了立刻失效、同键只算一次、强刷绕开缓存。
09-10 早上页面报上游的选股计划读不到 · timeout of 60000ms exceeded根因两半
缓存只活 10 分钟盘前 08:40 预热那份 08:50 就过期而装配又在锁外面09:02 页面几路
并发进来各跑一遍三十多秒的装配互相抢库连接叠加破了 60 这里把两条都钉住
离线不连库不起服务跑法python3 test_plan_cache.py预期最后一行是 ALL OK
"""
import sys
import threading
import time
import types
for _n in ("pandas", "pymysql", "psycopg"):
if _n not in sys.modules:
try:
__import__(_n)
except Exception: # noqa: BLE001
_m = types.ModuleType(_n)
if _n == "pandas":
_m.DataFrame = type("DataFrame", (), {})
sys.modules[_n] = _m
import api # noqa: E402
import plan # noqa: E402
def t(name, cond, extra=""):
print((" ok " if cond else " FAIL ") + name + ((" " + str(extra)) if not cond else ""))
assert cond, name
class _Spy:
"""替身:记录 collect 被调了几次、每次算多久。"""
def __init__(self, delay=0.0):
self.calls = []
self.delay = delay
self._lock = threading.Lock()
def collect(self, date, top, obs_top, theme_cap):
with self._lock:
self.calls.append((date, top, obs_top, theme_cap))
time.sleep(self.delay)
return {"date": date or "2026-09-09", "main": [], "observe": [], "_full": "该被丢掉"}
def _reset():
api._PLAN_CACHE.clear()
api._PLAN_BUILD_LOCKS.clear()
def test_hit_and_miss():
print("[命中与未命中]")
spy = _Spy()
_reset()
old_collect, old_latest = plan.collect, plan._latest_date
plan.collect = spy.collect
plan._latest_date = lambda tbl: "2026-09-09"
try:
d1, hit1 = api._plan_cached(None, 300, 100, 2, False)
d2, hit2 = api._plan_cached(None, 300, 100, 2, False)
t("第一次未命中、第二次命中", hit1 is False and hit2 is True)
t("只算了一次", len(spy.calls) == 1, spy.calls)
t("内部大字段被丢掉,不进缓存也不出接口", "_full" not in d1 and "_full" not in d2)
# 参数不同 = 不同的键。PMS 用 300/100人工联调常用 30/20两份互不干扰
api._plan_cached(None, 30, 20, 2, False)
t("参数不同要各算各的", len(spy.calls) == 2, spy.calls)
# 数据日换了,旧那份必须立刻失效,不能等缓存到期
plan._latest_date = lambda tbl: "2026-09-10"
_d, hit3 = api._plan_cached(None, 300, 100, 2, False)
t("上游换日后旧缓存立刻失效", hit3 is False and len(spy.calls) == 3)
# 强刷绕开缓存,但要把新结果写回去
n = len(spy.calls)
_d, hit4 = api._plan_cached(None, 300, 100, 2, True)
t("强刷绕开缓存", hit4 is False and len(spy.calls) == n + 1)
_d, hit5 = api._plan_cached(None, 300, 100, 2, False)
t("强刷之后缓存是新的", hit5 is True and len(spy.calls) == n + 1)
finally:
plan.collect, plan._latest_date = old_collect, old_latest
def test_single_flight():
print("[同键只算一次 —— 09-10 超时的那一半]")
spy = _Spy(delay=0.4) # 装配很慢,真实环境是三十多秒
_reset()
old_collect, old_latest = plan.collect, plan._latest_date
plan.collect = spy.collect
plan._latest_date = lambda tbl: "2026-09-09"
try:
results, errs = [], []
def one():
try:
results.append(api._plan_cached(None, 300, 100, 2, False))
except Exception as e: # noqa: BLE001
errs.append(e)
ths = [threading.Thread(target=one) for _ in range(8)]
t0 = time.time()
for x in ths:
x.start()
for x in ths:
x.join()
el = time.time() - t0
t("八路并发一个都没出错", not errs, errs)
t("八路都拿到了结果", len(results) == 8, len(results))
t("装配只跑了一遍(原来是八遍)", len(spy.calls) == 1, spy.calls)
t("总耗时接近单跑一次,不是八次叠加", el < 0.4 * 3, "%.2f" % el)
t("只有一路是未命中,其余都吃了缓存",
sum(1 for _d, h in results if not h) == 1, [h for _d, h in results])
finally:
plan.collect, plan._latest_date = old_collect, old_latest
def test_different_keys_not_blocked():
print("[不同参数互不阻塞]")
spy = _Spy(delay=0.4)
_reset()
old_collect, old_latest = plan.collect, plan._latest_date
plan.collect = spy.collect
plan._latest_date = lambda tbl: "2026-09-09"
try:
def one(top):
api._plan_cached(None, top, 100, 2, False)
ths = [threading.Thread(target=one, args=(x,)) for x in (300, 30, 50)]
t0 = time.time()
for x in ths:
x.start()
for x in ths:
x.join()
el = time.time() - t0
t("三种参数各算一次", len(spy.calls) == 3, spy.calls)
t("是并行不是排队", el < 0.4 * 2.5, "%.2f" % el)
finally:
plan.collect, plan._latest_date = old_collect, old_latest
def test_ttl_covers_trading_day():
print("[存活时间要盖住整个交易日]")
t("默认至少 8 小时08:40 预热要能管到收盘)",
api.PLAN_CACHE_SEC >= 8 * 3600, api.PLAN_CACHE_SEC)
t("锁字典与缓存字典一起清理,不会只涨不落",
"_PLAN_BUILD_LOCKS.pop" in open(api.__file__.replace(".pyc", ".py"), encoding="utf-8").read())
def main():
test_hit_and_miss()
test_single_flight()
test_different_keys_not_blocked()
test_ttl_covers_trading_day()
print("ALL OK — 选股计划缓存:命中与失效 / 同键只算一次 / 不同参数不互相阻塞 / 存活盖住交易日")
if __name__ == "__main__":
main()