你的FastAPI应用在多人同时修改数据时,会不会出现数据错乱或丢失?
试像一下,团队里一个用于管理内部资源的工具突然"抽风"了。几个同事同时提交申请,结果后台数据显示,有的申请记录神秘消失,有的资源数量对不上。一查日志,发现大家都在同一秒对同一个db.json文件进行了读写。这就是为了轻量而选用TinyDB(一个纯Python的、以JSON文件为存储的数据库)后,最有可能真切地感受到"并发"带来的痛。🎯
这篇文章,我将和你一起拆解这个问题,并分享如何用几个轻量级方案,让这个基于FastAPI和TinyDB的应用稳定地支撑起了日均数千次的并发请求。它不是一套放之四海而皆准的架构,但绝对是你在原型开发、小型工具或特定轻量场景下,性价比最高的解决方案。
📖 核心摘要
本文针对在FastAPI框架下使用TinyDB(JSON文件数据库)时遇到的并发写入数据冲突、错乱问题,深入浅出地解释了问题根源,并提供了从"文件锁"到"内存队列"再到"乐观锁"的三种由浅入深的实战解决方案,帮助你根据实际场景选择,确保数据一致性。
🚶♀️ 主要内容脉络
🔍 一、问题根源:为什么简单的JSON文件会"打架"?
🛠️ 二、解决方案:从"锁"到"队列"的三层防御
-
方案一:文件锁(fcntl / portalocker)------ 给文件上个"请勿打扰"牌
-
方案二:内存操作队列(asyncio.Queue)------ 让请求排好队,一个一个来
-
方案三:应用层乐观锁(版本号校验)------ "我改的时候,东西还是原来的样子吗?"
💻 三、实战代码:将方案融入FastAPI依赖项与路由
⚠️ 四、重要提醒与边界探讨:这不是银弹
🔍 第一部分:问题与背景
想象一下,TinyDB的db.json文件就是一个共享的笔记本。FastAPI的每个工作进程(Worker)就像一个快速记录员。
当用户A的请求到来时,记录员1打开笔记本,读到某个值(比如库存为5),准备将其改为4。
就在这"读到"和"改写"的毫秒之间 ,用户B的请求也来了。记录员2也打开了同一个笔记本,他读到的库存仍然是5(因为记录员1还没写回去),然后他也计算,将库存改为3。
结果就是,无论谁最后保存,另一个人的修改都会被完全覆盖。这就是典型的"并发写冲突"。在高并发的Web API场景下,这个问题会被急剧放大。
🛠️ 第二部分:核心原理与步骤
🎯 方案一:文件锁(最直接的物理隔离)
原理:在读写文件前,先给这个文件加一把系统级的锁。其他进程尝试加锁时,会被阻塞或失败,直到锁被释放。这就像给笔记本的房间门上了锁,一次只进一个人。
适用场景: 低并发(如内部工具)、读写不那么频繁的场景。
# 安装:pip install portalocker
import portalocker
def safe_update_db():
with open('db.json', 'r+') as f:
portalocker.lock(f, portalocker.LOCK_EX) # 获取独占锁
# 在这里安全地读取和修改数据
data = json.load(f)
data['counter'] += 1
f.seek(0)
json.dump(data, f)
f.truncate()
# 退出with块时,锁会自动释放
🎯 方案二:内存操作队列(单进程内的秩序维护者)
原理:利用Python的asyncio.Queue,将所有对TinyDB的写操作封装成任务,放入一个队列。由一个单独的"消费者"协程从队列中依次取出任务执行。这样,无论外部请求多么并发,对数据库的写操作都是串行化的。
优点: 完全在内存中操作,速度极快,避免了文件锁可能带来的死锁或跨平台问题。非常适合FastAPI的异步模式。
关键警告: 此方案仅在++单个服务进程++ 内有效。如果你使用多个工作进程(如Uvicorn with --workers 4),每个进程有自己的内存和队列,冲突依然会发生。此时需搭配方案一或方案三。
🎯 方案三:应用层乐观锁(基于版本的冲突检测)
原理:不阻止"读",只在"写"的时候检查冲突。为每条数据增加一个version字段。每次读取数据时,连带版本号一起读出。修改后写回时,检查当前文件中的版本号是否和自己读到的版本号一致。如果一致,则写入,并将版本号+1;如果不一致,则说明在此期间数据已被他人修改,本次操作失败,需要提示用户重试。
这就像两个人编辑在线文档,系统会提示你"在你编辑期间,文档已被他人更新"。
💻 第三部分:实战演示(整合方案二与三)
下面是一个在FastAPI中整合内存队列 与乐观锁的核心示例:
from fastapi import FastAPI, Depends, HTTPException
from contextlib import asynccontextmanager
import asyncio
from tinydb import TinyDB, Query
import json
from pydantic import BaseModel
app = FastAPI()
write_queue = asyncio.Queue()
db_path = 'db.json'
# 数据模型
class ItemUpdate(BaseModel):
item_id: int
new_value: str
read_version: int # 客户端传来的读取时的版本号
# 启动时启动写任务消费者
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时
asyncio.create_task(db_write_consumer())
yield
# 关闭时...
app = FastAPI(lifespan=lifespan)
async def db_write_consumer():
"""写操作消费者,常驻后台,串行处理写队列"""
while True:
task_data = await write_queue.get()
await _perform_safe_write(task_data)
write_queue.task_done()
async def _perform_safe_write(task_data: dict):
"""执行带乐观锁检查的写入"""
with TinyDB(db_path) as db:
Item = Query()
record = db.get(Item.id == task_data['item_id'])
if not record:
# 处理记录不存在的情况...
return
# 乐观锁检查!!!
if record['version'] != task_data['read_version']:
raise ValueError(f"数据版本冲突。当前版本{record['version']},提交版本{task_data['read_version']}")
# 通过检查,执行更新
db.update({
'value': task_data['new_value'],
'version': record['version'] + 1 # 版本号递增
}, Item.id == task_data['item_id'])
@app.put("/update_item/")
async def update_item(update: ItemUpdate):
"""更新接口"""
try:
# 将写操作封装成任务,放入队列,等待消费者处理
await write_queue.put(update.dict())
# 这里可以返回一个任务ID,让客户端轮询结果,或者使用WebSocket推送
return {"message": "更新请求已加入队列"}
except asyncio.QueueFull:
raise HTTPException(status_code=429, detail="系统繁忙,请稍后重试")
@app.get("/get_item/{item_id}")
async def get_item(item_id: int):
"""读取接口,返回数据和当前版本号"""
with TinyDB(db_path) as db:
Item = Query()
record = db.get(Item.id == item_id)
if record:
return {"value": record['value'], "version": record['version']}
raise HTTPException(status_code=404, detail="Item not found")
⚠️ 第四部分:注意事项与进阶思考
🚨 重要提醒:
-
性能瓶颈: 所有方案的核心都是"串行化写"。这意味着你的数据库写吞吐量存在上限。对于超高并发写入场景,JSON文件本身就会成为瓶颈。
-
多进程限制: 内存队列方案在单进程内完美,多进程需配合分布式锁(如Redis锁)或回归到数据库方案。
-
故障恢复: 队列中的任务在服务重启时会丢失。对数据一致性要求极高的场景,需要引入持久化消息队列(如RabbitMQ)或直接使用真正的数据库。
升华思考: 技术选型永远是权衡的艺术。TinyDB的优点是极致简单、无需外部服务。但当你的并发和一致性要求增长到一定阶段时,就是考虑升级到SQLite(支持更完善的事务和并发控制)、PostgreSQL等更强大数据库的时候了。本次实战的方案,是你从"玩具项目"平稳过渡到"生产系统"的一座关键桥梁。
---写在最后 ---
希望这份总结能帮你避开一些坑。如果觉得有用,不妨点个 赞👍 或 收藏⭐ 标记一下,方便随时回顾。也欢迎关注我,后续为你带来更多类似的实战解析。有任何疑问或想法,我们评论区见,一起交流开发中的各种心得与问题。