feat(db): 同步栈读写并发(_q 去锁)+ core.db 并发/跨loop 修复

auth_system._q:单语句读不再持 _DB_LOCK —— WAL 下每连接读一致性快照,
读写互不阻塞;旧实现把全部读也串行化且被写挡住。写事务(_x 与 24 个
with _DB_LOCK 块,经扫描均为写事务)仍全程串行,无写-写竞争。

core.db 并发修复(由测试暴露的真实缺陷):
- WAL 切换竞态:并发连接同时执行 PRAGMA journal_mode=WAL 争排他锁
  → 专用锁内串行切换 + 二次复查(busy_timeout 前置)
- 跨事件循环锁:模块级 asyncio.Lock 绑定首个 loop,多 loop 场景报
  "bound to a different event loop" → 锁按 loop 隔离

测试:25/25 通过(新增「读不被写锁阻塞」红转绿、读+同步写者并发 WAL、
异步并发读);
lint: ruff All checks passed;pyright 0 errors。
验收:冒烟 10/10、奇偶校验 2/2、uvicorn 真实启动 Flask 兜底+FastAPI 双通。
This commit is contained in:
snowy-yang committed 2026-09-26 01:03:18 +08:00
1 parent 52ed8b54fb
commit c77ef40fd5
4 files changed
+141 -16

No files matched your search

+37 -8
View File
@@ -25,21 +25,50 @@ import aiosqlite
DATA_DIR = Path(os.environ.get('SITE_DATA_DIR', 'data'))
DB_PATH = DATA_DIR / 'site.db'
# 写事务串行锁(惰性创建,避免跨事件循环绑定)
_WRITE_LOCK: asyncio.Lock | None = None
# 锁按事件循环隔离:asyncio.Lock 绑定创建它的 loop,跨 loop 复用会抛
# "bound to a different event loop"(迁移期 FastAPI loop + 同步桥 loop + 测试各自
# 的 loop 并存,必须分开持有)。
_LOOP_LOCKS: dict[tuple[int, str], asyncio.Lock] = {}
def _loop_lock(kind: str) -> asyncio.Lock:
key = (id(asyncio.get_running_loop()), kind)
lock = _LOOP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_LOOP_LOCKS[key] = lock
return lock
def _write_lock() -> asyncio.Lock:
global _WRITE_LOCK
if _WRITE_LOCK is None:
_WRITE_LOCK = asyncio.Lock()
return _WRITE_LOCK
"""写事务串行锁(每事件循环一个)。"""
return _loop_lock('write')
def _wal_lock() -> asyncio.Lock:
"""WAL 切换专用锁(不能用 _write_lock:execute() 已持它再调
connect→_configure 会自锁死)。"""
return _loop_lock('wal')
# 已确认切到 WAL 的库路径(首连设置一次;后续连接不再重复执行 PRAGMA 争排他锁)
_WAL_READY_FOR: str | None = None
async def _configure(conn: aiosqlite.Connection) -> None:
global _WAL_READY_FOR
conn.row_factory = aiosqlite.Row
await conn.execute('PRAGMA journal_mode=WAL') # 读写并发的前提
await conn.execute('PRAGMA busy_timeout=15000') # 遇写锁自旋等待,而非立即报错
# busy_timeout 先设:让后续语句遇锁自旋等待,而不是立刻报 database is locked
await conn.execute('PRAGMA busy_timeout=15000')
key = str(DB_PATH)
if _WAL_READY_FOR != key:
# 切换日志模式需要排他锁,且 `_WAL_READY_FOR` 的检查不是原子的——
# 并发连接须在专用锁内串行完成「切换 + 标记」,并二次复查,否则批量并发
# 连接会同时执行 PRAGMA journal_mode=WAL 争锁(实测 database is locked)
async with _wal_lock():
if _WAL_READY_FOR != key:
await conn.execute('PRAGMA journal_mode=WAL')
_WAL_READY_FOR = key
await conn.execute('PRAGMA synchronous=NORMAL') # WAL 下兼顾安全与吞吐
await conn.execute('PRAGMA foreign_keys=ON')