refactor: VIP 单飞锁改 asyncio.Lock,去掉每请求一次线程跳转

web/routers/account.py 的音质探测单飞锁原本是 core.state 里的 threading.Lock,
在协程里用 `await asyncio.to_thread(lock.acquire, True, 0.5)` 做有界等待,每次
VIP 请求都白付一次线程池调度。

为什么可以直接换:这把锁**只在协程里用**。原注释写「与 core.state 中同一把锁
共享」,但 grep 全仓只有 _detect_vip 一个消费者(sync 时代另有一个线程侧使用者,
早已不存在)。改为 asyncio.Lock + asyncio.wait_for(acquire(), 0.5),超时分支用
`except TimeoutError` 走「不等 8 级探测、直接用账号接口降级」的原有语义。

顺带把锁从 core.state 移出:core.state 自己声明「依赖仅限标准库
threading/collections」,而这把锁是 asyncio 的。现在它跟着唯一消费者走。

锁按事件循环隔离(以 id(loop) 为键):asyncio.Lock 绑定创建它的 loop,应用主 loop
与测试各自的 loop 并存,跨 loop 复用会抛 "bound to a different event loop"
(B2 迁移 auth_system 时踩过同一个坑)。

门禁:ruff 全过 / pyright 0-0-0 / pytest 382 项全绿(0 失败)。
This commit is contained in:
snowy-yang committed 2026-09-29 14:57:24 +08:00
1 parent 133beab492
commit 6c7f8f589e
2 files changed
+33 -12

No files matched your search

+3 -1
View File
@@ -92,7 +92,9 @@ GLOBAL_MUSIC_U_CACHE: _TimedValue = {'v': None, 'ts': 0.0}
# 服务器网易云账号昵称缓存(10 分钟):非管理员可见响应中遮蔽真实昵称 # 服务器网易云账号昵称缓存(10 分钟):非管理员可见响应中遮蔽真实昵称
SERVER_NICK_CACHE: _NickCache = {'nick': None, 'ts': 0.0} SERVER_NICK_CACHE: _NickCache = {'nick': None, 'ts': 0.0}
SERVER_NICK_LOCK = threading.Lock() # 刷新单飞锁,防并发重复打上游 SERVER_NICK_LOCK = threading.Lock() # 刷新单飞锁,防并发重复打上游
VIP_PROBE_LOCK = threading.Lock() # 音质探测单飞锁(有界等待) # 注:音质探测的单飞锁(原 VIP_PROBE_LOCK)已移出本模块——本模块声明「依赖仅限
# 标准库 threading/collections」,而那是把 asyncio 锁;且它全仓只有
# web/routers/account.py 一个消费者(sync 时代遗留的「共享」注释已不成立)。
# 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口, # 全局账号 VIP 探测缓存(5 分钟):probe 一次要打 8 次网易云接口,
# 公网下账号栏高频刷新会连累服务器账号触发风控。 # 公网下账号栏高频刷新会连累服务器账号触发风控。
+30 -11
View File
@@ -6,14 +6,15 @@
- POST/GET /api/v1/global/playlists ↔ Flask /global/playlists(管理员) - POST/GET /api/v1/global/playlists ↔ Flask /global/playlists(管理员)
- POST/GET /api/v1/global/playlist ↔ Flask /global/playlist(管理员) - POST/GET /api/v1/global/playlist ↔ Flask /global/playlist(管理员)
""" """
import asyncio
import hashlib import hashlib
import time import time
from typing import Annotated, Any from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
import music_api import music_api
from core.state import VIP_CACHE, VIP_PROBE_LOCK, VIP_USER_CACHE from core.state import VIP_CACHE, VIP_USER_CACHE
from music_api import APIException from music_api import APIException
from web import ratelimit from web import ratelimit
from web.deps import SiteUser, current_user, require_admin from web.deps import SiteUser, current_user, require_admin
@@ -31,8 +32,25 @@ _VIP_NAMES = {
0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP', 0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP',
} }
# 探测单飞:与 core.state 中同一把锁共享,避免并发请求各自串行 8 级探测 # 音质探测单飞锁:probe 一次要打 8 次网易云接口,并发请求各自串行探测会连累
_VIP_PROCESS_LOCK: Any = VIP_PROBE_LOCK # 服务器账号触发风控,故同一时刻只放一个请求真探测,其余直接拿账号接口的降级结果。
#
# 用 asyncio.Lock:本模块是这把锁**唯一**的消费者(原注释「与 core.state 中同一把
# 锁共享」是 sync 时代残留,当时另有一个线程侧使用者,现已不存在),故从
# core.state 移到这里,也让 core.state 保持「只依赖 threading/collections」。
# 按事件循环隔离:asyncio.Lock 绑定创建它的 loop,应用主 loop 与测试各自的 loop
# 并存,跨 loop 复用会抛 "bound to a different event loop"。
_PROBE_LOCKS: dict[int, asyncio.Lock] = {}
def _probe_lock() -> asyncio.Lock:
"""当前事件循环的探测单飞锁。"""
key = id(asyncio.get_running_loop())
lock = _PROBE_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_PROBE_LOCKS[key] = lock
return lock
def _vip_name(vip_type: int) -> str: def _vip_name(vip_type: int) -> str:
@@ -46,14 +64,15 @@ def _cookies() -> dict[str, str]:
async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]: async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
"""检测账号会员类型与可解析音质。 """检测账号会员类型与可解析音质。
单飞锁为 threading.Lock:异步上下文里用 to_thread 有界等待 0.5s, 单飞锁为 asyncio.Lock:有界等待 0.5s,拿不到锁的请求直接用账号接口给
拿不到锁的请求直接用账号接口给降级结果(不等 8 级探测)。 降级结果(不等 8 级探测)。
""" """
import asyncio
api = music_api.NeteaseAPI() api = music_api.NeteaseAPI()
acquired = await asyncio.to_thread(_VIP_PROCESS_LOCK.acquire, True, 0.5) lock = _probe_lock()
if not acquired: try:
await asyncio.wait_for(lock.acquire(), 0.5)
except TimeoutError:
# 已有请求在探测:不等它(等 8 级探测会拖长响应),直接给降级结果
info = await api.get_account_info(cookies) info = await api.get_account_info(cookies)
if info: if info:
return info.get('vipType', 0), 'account', [] return info.get('vipType', 0), 'account', []
@@ -66,7 +85,7 @@ async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
return info.get('vipType', 0), 'account', levels_ok return info.get('vipType', 0), 'account', levels_ok
return probe.get('vip_type', 0), 'probe', levels_ok return probe.get('vip_type', 0), 'probe', levels_ok
finally: finally:
_VIP_PROCESS_LOCK.release() lock.release()