Python异步操作JSON存储时字典重复键、列表重复元素排查
这是异步并发场景下的典型竞态条件(Race Condition),和你的存在性校验逻辑本身无关。
你的逻辑运行在异步协程环境中,Telegram机器人会并行处理多条消息请求,而「读取JSON文件→校验键/元素是否存在→修改内存数据→写回文件」这整套流程不是不可拆分的原子操作,会出现如下交叉执行的时序:
- 请求1触发,读取到磁盘上旧版本的JSON文件,加载到内存做校验
- 请求1还没来得及把修改后的内容写回磁盘,请求2切入,同样读取到和请求1完全一致的旧版本文件
- 请求1校验完成,判断目标键/元素不存在,写入新内容后覆盖磁盘文件
- 请求2基于自己手里的旧数据做校验,同样判断目标键/元素不存在,再次写入同一条内容覆盖文件
你看到的JSON重复键属于文件损坏的直接表现:Python原生字典本身不允许重复键,标准JSON语法也禁止对象内出现同名键,之所以能导出带重复键的内容,是因为你用w模式打开文件时会先清空原文件内容,再逐字节写入新数据,如果写入过程中被其他协程打断,就会留下写了一半的损坏文件,后续读写操作基于损坏文件执行,就会出现各种不符合预期的重复问题。
你贴的列表操作代码本身还有语法疏漏:with ('mylist.json','w') as f: 漏写了open调用,正常运行会直接报错。
1. 给全流程加异步锁
用协程锁把单个文件对应的「读-改-写」全流程包裹起来,保证同一时间只有一个协程能操作对应文件,彻底避免交叉执行。参考实现:
import asyncio import json import os # 为每个需要并发操作的文件初始化全局唯一锁 user_file_lock = asyncio.Lock() list_file_lock = asyncio.Lock() async def track_user(update): async with user_file_lock: users = {} if os.access("userlist.json", os.R_OK): with open('userlist.json', 'r', encoding='utf-8') as f: # 捕获JSON解析异常,兼容之前已经损坏的文件 try: users = json.load(f) except json.JSONDecodeError: users = {} user_id = str(update.message.from_user.id) currentuser = [ user_id, update.message.from_user.username, update.message.from_user.first_name, update.message.from_user.last_name, False, 1 ] if user_id in users: currentuser[5] += users[user_id][5] users[user_id] = currentuser # 先写临时文件,再原子替换,避免写一半损坏原文件 with open('userlist.json.tmp', 'w', encoding='utf-8') as f: json.dump(users, f, ensure_ascii=False) os.replace('userlist.json.tmp', 'userlist.json')
列表操作的逻辑完全一致:把读文件、判断元素存在性、追加元素、写回文件的全流程放到对应锁的上下文里即可。
2. 替换文件写入逻辑
不要直接用w模式打开原文件写入,改用「临时文件写入+原子替换」的方案:先把完整内容写入同目录下的临时文件,确认写入完成后,用操作系统提供的原子替换接口替换原文件。这种方式下无论写入过程中出现程序崩溃、进程中断、协程切换,原文件始终要么是完整的旧版本,要么是完整的新版本,不会出现半写损坏的问题。
3. 长期优化方案
如果后续机器人消息量、用户量上涨,不要再用JSON文件做持久化存储,换成SQLite这类轻量嵌入式数据库即可,数据库原生支持并发控制、原子写入,不需要业务层自己维护锁逻辑,稳定性会提升很多。
内容的提问来源于stack exchange,提问作者Starcatcher

