1. 为什么FastAPI + TinyDB组合会踩并发坑?
第一次用FastAPI搭配TinyDB开发小型API时,我天真地以为这个组合堪称完美——直到凌晨三点被客服电话吵醒,告知用户数据出现大规模错乱。查看日志才发现,当并发请求量达到20QPS时,订单状态、用户余额等关键字段开始出现随机覆盖。这个惨痛教训让我彻底明白:轻量级不等于安全。
TinyDB作为纯Python实现的文档型数据库,默认采用单文件JSON存储。当多个请求同时修改同一条记录时,会出现经典的"写覆盖"问题:请求A读取记录X(版本1)→请求B读取记录X(版本1)→请求A修改字段并写入X(版本2)→请求B基于旧版本1修改其他字段后写入(覆盖版本2)。最终结果就是部分修改神秘消失。
FastAPI的异步特性加剧了这个问题。虽然uvicorn默认使用主线程+工作线程模式(可通过--workers指定),但每个工作线程内部还有异步事件循环。当多个并发请求修改同一条TinyDB记录时,GIL(全局解释器锁)的释放会让时间片切换变得更加不可预测。我曾用locust做过压力测试:在4核机器上模拟50并发用户连续写入,不到30秒就能复现数据错乱。
关键发现:TinyDB的文档级原子性只在单线程下有效。当多个线程/协程同时操作时,即便使用
with语句包裹写入操作,也无法避免竞争条件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种实战解决方案的深度对比
2.1 文件锁方案:简单但性能堪忧
最直观的解决思路是给数据库文件加锁。Python的fcntl模块可以提供跨进程文件锁:
python复制import fcntl
from tinydb import TinyDB
class LockedTinyDB(TinyDB):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._lockfile = open(self._storage._path + '.lock', 'w')
def _acquire_lock(self):
fcntl.flock(self._lockfile, fcntl.LOCK_EX)
def _release_lock(self):
fcntl.flock(self._lockfile, fcntl.LOCK_UN)
def insert(self, document):
self._acquire_lock()
try:
return super().insert(document)
finally:
self._release_lock()
实测发现:在10并发下平均响应时间从12ms暴涨到210ms。更糟的是,这种锁无法在Windows系统稳定工作(需要改用msvcrt.locking)。适合临时方案,但长期运行会成瓶颈。
2.2 内存缓存+批量写入:牺牲实时性换吞吐
借鉴Redis的AOF策略,可以引入内存缓存层:
python复制from collections import defaultdict
from threading import Timer
class BufferedTinyDB:
def __init__(self, db_path, flush_interval=5):
self._db = TinyDB(db_path)
self._buffer = defaultdict(dict)
self._flush_timer = Timer(flush_interval, self._flush_buffer)
self._flush_timer.start()
def update_doc(self, doc_id, updates):
self._buffer[doc_id].update(updates)
def _flush_buffer(self):
with self._db.storage.write_lock:
for doc_id, changes in self._buffer.items():
self._db.update(changes, doc_ids=[doc_id])
self._buffer.clear()
self._flush_timer = Timer(flush_interval, self._flush_buffer)
self._flush_timer.start()
这种方案在我的日志收集服务中表现良好,将磁盘IO降低92%。但有两个致命缺陷:1) 进程崩溃会丢失内存中的数据 2) 无法实现实时查询。需要配合WAL(Write-Ahead Logging)机制才能用于生产环境。
2.3 分片存储:空间换并发度
TinyDB支持自定义存储引擎。通过按文档ID哈希分片到不同文件,可以降低冲突概率:
python复制from pathlib import Path
from tinydb.storages import JSONStorage
class ShardedStorage(JSONStorage):
def __init__(self, path, shards=8):
self.shards = shards
self._handles = [
open(f"{path}.shard_{i}", 'a+')
for i in range(shards)
]
def _get_handle(self, doc_id):
return self._handles[doc_id % self.shards]
def write(self, data, doc_id=None):
handle = self._get_handle(doc_id)
handle.seek(0)
json.dump(data, handle)
handle.truncate()
在电商订单系统中测试,分片数设为CPU核心数的4倍时,500并发下的错误率从17%降至0.3%。但跨分片事务依然无解,且备份恢复流程会变得复杂。
2.4 换用SQLite:最佳平衡方案
最终我选择了最务实的方案——换用SQLite。只需修改两行代码:
python复制# 安装支持SQLite的TinyDB变体
pip install tinydb-sqlite
from tinydb_sqlite import SQLiteStorage
db = TinyDB('db.sqlite', storage=SQLiteStorage)
SQLite的WAL模式(Write-Ahead Logging)支持多读单写,完全兼容ACID。实测显示:
- 在100并发下零错误
- 平均延迟仅增加5ms
- 支持原子事务操作
python复制with db.transaction():
user = db.get(doc_id=user_id)
if user['balance'] >= order_total:
db.update({'balance': user['balance'] - order_total}, doc_ids=[user_id])
db.insert({'order_id': new_order_id, 'status': 'paid'})
3. 高级场景下的优化技巧
3.1 热点文档的乐观并发控制
对于高频更新的计数器类数据,可以引入版本号校验:
python复制def atomic_increment(doc_id):
while True:
doc = db.get(doc_id=doc_id)
new_value = doc['count'] + 1
affected = db.update(
{'count': new_value, 'version': doc['version'] + 1},
cond=lambda x: x['version'] == doc['version'],
doc_ids=[doc_id]
)
if affected:
break
这种CAS(Compare-And-Swap)模式在抢购场景下表现优异,配合指数退避算法可避免活锁。
3.2 读写分离架构设计
对于读多写少的场景,可以这样扩展:
python复制from copy import deepcopy
class ReplicaTinyDB:
def __init__(self, master_path, replica_path):
self.master = TinyDB(master_path)
self.replicas = [TinyDB(replica_path) for _ in range(3)]
def insert(self, document):
doc_id = self.master.insert(document)
for replica in self.replicas:
replica.insert(deepcopy(document))
return doc_id
实际部署时需要处理副本同步延迟问题。我通常会用修改时间戳+定期校验的补偿机制。
3.3 混合持久化策略
关键数据用SQLite,非关键数据保留TinyDB:
python复制critical_db = TinyDB('critical.sqlite', storage=SQLiteStorage)
log_db = TinyDB('logs.json')
@app.post("/order")
async def create_order(order: Order):
with critical_db.transaction():
# 扣款等核心操作
...
log_db.insert(order.dict()) # 日志可容忍偶尔丢失
这种分层设计在物联网设备数据收集中特别有效,既保证关键状态安全,又维持高吞吐。
4. 生产环境部署的注意事项
4.1 监控指标埋点
必须监控的三个关键指标:
- 文件锁等待时间(如果采用方案1)
- 缓冲队列长度(如果采用方案2)
- SQLite的WAL文件大小(如果采用方案4)
推荐使用Prometheus客户端:
python复制from prometheus_client import Gauge
db_lock_wait = Gauge('tinydb_lock_wait_seconds', 'File lock contention')
SQLITE_WAL_SIZE = Gauge('sqlite_wal_size_bytes', 'WAL file size')
@app.middleware("http")
async def monitor_db(request: Request, call_next):
start_time = time.time()
if request.url.path.startswith("/api"):
with db_lock_wait.time():
response = await call_next(request)
wal_size = Path('db.sqlite-wal').stat().st_size
SQLITE_WAL_SIZE.set(wal_size)
return response
4.2 备份策略优化
不同于传统数据库,文件型备份需要注意:
- 使用COPY而非文件系统快照
- 备份前执行
PRAGMA wal_checkpoint(SQLite) - 校验备份文件的JSON完整性(TinyDB)
我的自动化脚本示例:
bash复制#!/bin/bash
# SQLite备份
sqlite3 production.sqlite "PRAGMA wal_checkpoint(FULL);"
cp production.sqlite backup/$(date +%s).sqlite
# TinyDB备份
python -c "import json; json.load(open('db.json'))" && \
cp db.json backup/$(date +%s).json
4.3 压力测试方法论
用Locust模拟真实场景:
python复制from locust import HttpUser, task, between
class TinyDBUser(HttpUser):
wait_time = between(0.1, 0.5)
@task(3)
def read_data(self):
self.client.get("/item/42")
@task(1)
def write_data(self):
self.client.post("/item", json={"id": 42, "stock": random.randint(1,100)})
关键参数:
- 读写比例(建议3:1)
- 思考时间(think time)符合泊松分布
- 渐进式增加并发用户数
我在实际测试中发现,当SQLite的WAL文件超过100MB时,需要关注磁盘IOPS性能。此时应考虑归档历史数据或升级SSD。
