fix(stats): 添加文件锁(fcntl.flock)防止并发竞态

- 添加 _acquire_lock() 和 _release_lock() 辅助函数
- _load_stats_data() 和 _atomic_write() 使用文件锁保护
- 使用独立的 .lock 文件作为锁
- 防止并发读写导致数据损坏
This commit is contained in:
2026-03-09 14:50:03 +08:00
parent 9b15766ab3
commit e136939a25
+45 -9
View File
@@ -8,6 +8,7 @@ Cross 模式统计报告模块
import json import json
import os import os
import tempfile import tempfile
import fcntl
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
@@ -26,12 +27,32 @@ def _ensure_stats_dir():
STATS_DIR.mkdir(parents=True, exist_ok=True) STATS_DIR.mkdir(parents=True, exist_ok=True)
# 锁文件路径
LOCK_FILE = STATS_DIR / "stats_data.lock"
def _acquire_lock(lock_file_path: Path):
"""获取文件锁"""
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
lock_fd = open(lock_file_path, 'w')
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX)
return lock_fd
def _release_lock(lock_fd):
"""释放文件锁"""
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN)
lock_fd.close()
def _load_stats_data() -> Dict[str, Any]: def _load_stats_data() -> Dict[str, Any]:
"""加载统计数据""" """加载统计数据(带锁保护)"""
if not STATS_FILE.exists(): if not STATS_FILE.exists():
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}} return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
lock_fd = None
try: try:
lock_fd = _acquire_lock(LOCK_FILE)
with open(STATS_FILE, 'r', encoding='utf-8') as f: with open(STATS_FILE, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
@@ -44,25 +65,40 @@ def _load_stats_data() -> Dict[str, Any]:
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}} return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
except IOError: except IOError:
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}} return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
finally:
if lock_fd:
_release_lock(lock_fd)
def _atomic_write(data: Dict[str, Any]): def _atomic_write(data: Dict[str, Any]):
"""原子写入统计数据""" """原子写入统计数据(带锁保护)"""
_ensure_stats_dir() _ensure_stats_dir()
# 写入临时文件 lock_fd = None
fd, temp_path = tempfile.mkstemp(dir=STATS_DIR, suffix='.tmp') temp_path = None
try: try:
with os.fdopen(fd, 'w', encoding='utf-8') as f: lock_fd = _acquire_lock(LOCK_FILE)
json.dump(data, f, ensure_ascii=False, indent=2)
# 写入临时文件
with tempfile.NamedTemporaryFile(
mode='w',
encoding='utf-8',
dir=STATS_DIR,
suffix='.tmp',
delete=False
) as temp_file:
json.dump(data, temp_file, ensure_ascii=False, indent=2)
temp_path = temp_file.name
# 原子重命名 # 原子重命名
os.replace(temp_path, STATS_FILE) os.replace(temp_path, STATS_FILE)
except Exception: temp_path = None # 防止在 finally 中再次删除
finally:
if lock_fd:
_release_lock(lock_fd)
# 清理临时文件 # 清理临时文件
if os.path.exists(temp_path): if temp_path and os.path.exists(temp_path):
os.unlink(temp_path) os.unlink(temp_path)
raise
def _filter_sensitive_data(data: Dict[str, Any]) -> Dict[str, Any]: def _filter_sensitive_data(data: Dict[str, Any]) -> Dict[str, Any]: