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 os
import tempfile
import fcntl
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Any, Optional
@@ -26,12 +27,32 @@ def _ensure_stats_dir():
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]:
"""加载统计数据"""
"""加载统计数据(带锁保护)"""
if not STATS_FILE.exists():
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
lock_fd = None
try:
lock_fd = _acquire_lock(LOCK_FILE)
with open(STATS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
@@ -44,25 +65,40 @@ def _load_stats_data() -> Dict[str, Any]:
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
except IOError:
return {"events": [], "metadata": {"created_at": datetime.now().isoformat()}}
finally:
if lock_fd:
_release_lock(lock_fd)
def _atomic_write(data: Dict[str, Any]):
"""原子写入统计数据"""
"""原子写入统计数据(带锁保护)"""
_ensure_stats_dir()
# 写入临时文件
fd, temp_path = tempfile.mkstemp(dir=STATS_DIR, suffix='.tmp')
lock_fd = None
temp_path = None
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
lock_fd = _acquire_lock(LOCK_FILE)
# 写入临时文件
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)
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)
raise
def _filter_sensitive_data(data: Dict[str, Any]) -> Dict[str, Any]: