news 2026/10/21 6:21:08

并发写入数据丢失排查:SQLite事务与缓存一致性实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
并发写入数据丢失排查:SQLite事务与缓存一致性实战

写这篇的时候,已经是30天挑战的第三天了。所谓30天挑战,是我给自己的一个硬性约定:用三十天时间,从零独立做一个能真正跑起来的轻量级应用,每天推进一个明确里程碑,并把当天踩过的坑、想清楚的事整理成记录。前面两天,我搭好了开发环境,也把最外层的界面雏形跑通了,到了DAY 3,原计划是直接开写业务逻辑,可真动起手来我发现不行——所有业务逻辑都建立在数据存储上,而这个应用的存储方案我还没定。于是我把DAY 3的目标临时调整为核心数据层的设计,没想到这一调整,直接把全项目最大的一个坑给踩出来了:并发环境下数据莫名其妙地消失。这篇文章就是那天完整过程的记录。

1. DAY 3的任务拆解:为什么第三天就要碰数据层

1.1 整个30天计划的推进节奏

先花点时间交代一下这个挑战的背景,方便第一次看到这个系列的朋友跟上。整体安排其实很朴素:第一个星期打地基(开发环境、项目结构、数据存储、核心接口),第二个星期做业务(卡片记录、标签、搜索),第三和第四个星期打磨交互和性能,最后留几天做测试和部署。每天必须有一个"看得见的变化",不是读读书写写笔记那种,而是真正能跑、能看、能点的东西。

前两天的进展是这样的。DAY 1确定项目形态——一个给个人用户使用的知识卡片管理应用,核心功能就三块:卡片的增删改查、标签整理、全文搜索。听起来不复杂,但这三类功能恰好对应数据层三个完全不同的需求场景:高频写入、结构化查询、文本匹配。这意味着数据层不是简单选一个数据库就行,还得考虑索引、缓存和并发访问。DAY 2我把最外层的静态界面搭了出来,包括卡片列表和编辑页的雏形,所有数据都是写死的假数据。界面能看了,假数据也摆上去了,但下一步该往界面后面接真实数据了,这就绕不开存储方案。

1.2 今天的核心目标:数据层必须"一次做对"

之所以说"一次做对",是因为数据层有一个非常讨厌的特性:问题发现得越晚,修复成本越高。界面写错了删除一个组件就行,但存储方案选错了,那意味着所有业务代码、查询语句、缓存逻辑全部推翻重来。我老听到有人做项目先用假数据顶着,做到最后再换数据库,十个有九个在迁移当天痛不欲生。

所以DAY 3我给自己定的验收标准就三条。第一,数据能持久化存储,应用重启后数据不丢;第二,支持一定规模的并发读写,不至于几十个请求就把数据库搞崩;第三,查询和写入的性能要能支撑"卡片应用"这种日常使用强度——不是互联网大厂那个量级,但也不能每操作一次要等一秒钟。

这个"一定规模的并发"后来被我自己定义成了一个具体的数字:能稳定承受至少十个并发线程同时写入,单次写入失败率为零,延迟不超过几十毫秒。正是这个在当时看起来有点自找麻烦的验收标准,逼着我撞上了后面那个数据消失的坑。如果一个要求低一点,比如"只要能跑就行",那我可能到现在都不会发现那个问题。

2. 数据层技术选型:为什么最终选了"轻量SQLite+缓存"组合

2.1 备选方案的横向对比

在动手之前,我把当前主流的存储方案过了一遍。考虑到这是个人应用的轻量级项目,不会有多高并发,也不能太复杂,我把候选方案分成了四档,整理成一张表方便比较:

方案部署成本性能表现可靠性适合场景
JSON文件直接落盘零读写全量数据,数据量稍大就慢差,进程崩溃可能丢数据纯原型验证
轻量级嵌入式数据库零配置单机并发读写足够,大量级性能优秀高,支持事务和WAL个人应用、桌面应用、小服务
内存数据库+持久化需额外进程或库极快,但持久化要自己负责中等,重启有风险缓存加速场景
重型数据库需安装服务、账号配置强,但资源占用远超需求高多用户、高并发的正式服务

这个表做出来之后,答案其实已经很明确了。JSON文件方案是最容易想到的,毕竟Python里几行代码就能读写,看起来最省事,但稍微设想一下数据量就能发现问题:当卡片数量到几千张以后,每次更新一张卡片都需要把整个文件加载进内存、改完再整体写回,性能和可靠性都很差,而且并发场景下两个进程同时写同一个文件,数据丢失几乎是必然的。重型数据库更不用说了,给这个个人应用配一个数据库服务端,明显是杀鸡用牛刀,光环境配置和日常维护就已经违背了"轻量级"的项目初衷。

2.2 嵌入式数据库是当前阶段的最优解

嵌入式数据库这个选项,最终赢在了四点上。第一是零部署成本——它不是一个独立的服务进程,而是以库文件的形式内嵌在应用里,不需要装客户端、不需要配端口、不需要管账号权限。第二是数据存储在本地文件里,整个应用就一个数据文件,备份就是把文件复制走。第三是它支持完整的SQL语法和事务,这意味着后续业务复杂了我不用换方案。第四是它自带事务机制,能应对相当程度的并发读写,踩对了模式之后性能并不差。

我最终选的是嵌入式数据库界最普及的那个数据库引擎,搭配外层的简单内存缓存。这里有个容易被新手忽视的点:嵌入式数据库虽然叫"轻量级",但它的并发能力绝不等于"没有",关键看你怎么使用它。它默认的日志模式、连接方式、事务提交策略,差之毫厘谬以千里。我当时并不知道后面会发生什么,现在回看,这个架构选择本身没有错,错的是使用细节。

2.3 补充说明:缓存层不是必须的,但它兜住了很多性能问题

有一个心得值得单独说:嵌入式数据库的单次独立查询其实很快,但在频繁读写的场景下,反复打开连接、反复提交事务的开销会累积到肉眼可见的程度。所以我在数据层外面加了一个简单的内存缓存——热数据先查内存,查不到再走数据库。这个缓存的粒度是"按卡片ID缓存单条数据",不做复杂的整表缓存,避免出现缓存失效黑洞。

这个缓存层后来在压力测试时给了我一个不小的教训,后面详细说。

3. 真正的麻烦:并发写入时数据竟然"凭空消失"

3.1 现象描述:日志显示成功,数据却不见了

数据层实现的第一个版本,逻辑上非常简单:每条数据进来,打开连接、执行插入、提交事务、关闭连接。写了一个入门的冒烟测试,单线程跑了十几条,全部正常。但当我按照自己定的验收标准去压它的时候,诡异的事情发生了。

我写了一个多线程脚本模拟并发写入:十个线程同时往数据库里插数据,每个线程插一百条,总共一千条写入。跑完脚本之后,程序打印的日志显示几乎全部成功,偶发几条报错说数据库被锁。我心想即便有几条失败,至少也有九百九十多条成功了吧,于是去数据库里数了一下实际记录数。结果让我当场愣住——实际记录只有六百多条。

差了将近三百条。这是整条并发链路里最扎眼的问题:程序告诉我它写成功了,但数据却并没有真正落进数据库。如果这是在正式环境里,用户辛辛苦苦录入的卡片,系统提示"已保存",刷新之后卡片没了,这是最严重的信任危机。比"直接报错"可怕得多。

3.2 我试着绕开问题:三个典型的"表面解决"

遇到问题之后,我先后试了三种看似对症的解法,现在回头看,全都是治标不治本。

第一个解法是给每次写入加重试机制:报错就等待随机时间然后重新插入。这个做法确实把报错的概率降低了,但我很快就发现,重试只能解决"数据库被锁"这种显式错误,而那些"日志显示成功但数据没了"的静默丢失根本没触发重试逻辑,问题是原封不动。也就是说,我编了一个很高大上的兜底方案,兜底方案本身却根本没被调起来。

第二个解法是加大量延迟,让线程之间错开写入时间。测试了一遍,成功率确实上去了,但这种"通过拖慢速度来避免冲突"的思路完全没有可扩展性,一旦真实用户的操作节奏变快,问题马上会回来。这不过是用性能换稳定,且并没有真的理解冲突的本质。

第三个解法的教训最大。我生了一肚子气之后,干脆把入口数据一股脑先写进内存缓存,然后异步批量往数据库刷。测试结果是:不报错了,速度也快了,看上去完美。但冷静下来做了个极端压力测试,数据仍然大量丢失,而问题被缓存层完美地掩盖了——日志显示数据都进了缓存,数据库却根本没收到。那一刻我才真正意识到,缓存层永远只能兜住"读"的性能问题,绝不能反过来掩盖"写"的可靠性问题。

3.3 用最小化场景复原丢数据的完整过程

在尝试了各种绕行方案无果之后,我决定不再猜,而是用最原始的方法:做最小化复现实验。我把代码尽可能精简到一个脚本里,只保留"多线程插入数据库"这个最小场景,没有任何业务逻辑干扰。这个脚本大概长这样:

import sqlite3 import threading def writer(thread_id): conn = sqlite3.connect("app.db") for i in range(100): conn.execute("INSERT INTO logs (thread_id, seq) VALUES (?, ?)", (thread_id, i)) conn.commit() conn.close() threads = [] for t in range(10): th = threading.Thread(target=writer, args=(t,)) threads.append(th) th.start() for th in threads: th.join() # 检查实际落库数量 conn = sqlite3.connect("app.db") print(conn.execute("SELECT COUNT(*) FROM logs").fetchone()[0]) conn.close()

这个脚本跑了很多次,结果极其稳定:成功数永远停留在六百多到七百多之间,那个丢失比例让我有点后背发凉。更让我警觉的是,它每次丢的都是固定的几个线程的部分序号段,这意味着不是随机冲突,而是有某个线程写了一半被中断,之前提交的半个事务或者连接本身出了问题。

4. 完整排查链路:从日志特征到根因所在的逐步收敛

4.1 第一步:从完整的日志里找"丢数据"时的共同特征

排查这件事,最忌讳的做法是看到一个问题就立刻猜一个原因然后动手改。我给自己定了一个原则:先不碰代码,先看日志。我重新把多线程脚本跑了一遍,这次每个线程每写一条就打一条带时间戳的日志,级别精确到毫秒,然后把所有日志按时间排序,找那些"日志显示成功但实际没入库"的记录位置。

结果发现了一个非常有意思的模式:丢失基本都发生在两个线程互相交叉写入的那个时间点附近。具体来说,线程A写了一部分,日志显示成功,紧接着线程B获得连接写了几条,也显示成功,但线程A之前那一小批数据最终没有体现在最终表里。这个现象让我一度想到是不是数据库提交顺序的问题,后来又怀疑是不是多线程共享了什么状态变量,排查完发现都不对。

让我彻底转向的线索是:数据库文件里出现的"database is locked"报错,和静默丢失之间存在时间相关性。每次锁报错前后的一小段时间里,静默丢失的概率特别高。这说明两者有着同一个底层根源,而不是两个独立的问题。

4.2 第二步:锁定连接的事务边界行为

到这里我开始怀疑:是不是每个线程使用的连接在并发举止上有什么我不知道的默认行为。于是回去翻这个数据库引擎的事务文档,重点看两个点:默认的日志模式是什么,以及并发场景下事务的提交时机到底由什么决定。

文档明确告诉我,默认的日志模式是回滚日志模式,这个模式下同一时刻只允许一个连接写文件,其他连接的写入会被阻塞,等待时间超过阈值就直接返回锁错误。而锁错误本身只是一个外在表现,真正危险的是另一个细节:在这个模式下,如果一条写入指令在等待锁的过程中超时,SQLite会向应用报告错误;但如果这条指令被异常中断且上层业务没有做回滚,这个表面上"还没开始"的事务可能留下一个半提交状态,后续的写入无法感知,最终数据就丢掉了。

简而言之,根因有两个层次。表面层次是:每个线程各自打开连接、每一条数据各自提交,导致锁竞争极其剧烈,锁错误频发。深层层次是我自己代码里的问题:我对每一条SQL都单独调用了commit,大量短事务频繁抢占锁,在高竞争下就触发了数据库的错误处理路径,而我的业务代码对这个错误路径的响应是缺失的,于是数据被上层误判为"已提交成功"。

4.3 第三步:用"单写连接+批量事务"替代"多连接+单条提交"

问题的修复方向,坦白说文档里没有直接写"你应该如何写代码",但把所有线索拼在一起之后,答案已经清楚地指向了两个调整:第一,把所有写入请求统一到单个数据库连接上,由这个连接顺序排队执行;第二,把频繁的单条提交改为批量提交。

第一点的理由很直接:既然这个数据库引擎本质上要求同一时刻只有一个写入者,那我们就在应用层面主动做好这个调度,而不是让十个线程去抢一个数据库锁。用一个锁把写入操作包起来,代码变成:

import sqlite3 import threading conn = sqlite3.connect("app.db") conn.execute("PRAGMA journal_mode=WAL") write_lock = threading.Lock() def batch_insert(items): with write_lock: conn.executemany("INSERT INTO logs (thread_id, seq) VALUES (?, ?)", items) conn.commit()

注意第4行的 PRAGMA journal_mode=WAL:我把数据库切换到了预写日志模式,也就是所谓的写前日志模式。这个模式带来的直接好处是读写可以并发,写入之间仍然要互斥,但读取不再被写入阻塞,对"卡片应用"这种偶尔读频繁的场景友好得多。

第二点则是一个更彻底的收益来源。单条提交每次都要触发一次落盘操作,哪怕数据在缓存里,commit那一下也要等磁盘确认。批量提交则把一百条写入攒在一起,只落盘一次,既减少了总落盘次数,又减小了锁持有的频次。同一个锁持有的总时间变短之后,其他线程排队的等待时间自然就下来了。

4.4 修完后的验证结果

修复后第一件事,是重新跑同样规模的压力测试。十线程、每线程一百条写入,加上同样的日志和计数逻辑,连跑十轮:

轮次尝试写成功落库丢失数耗时(ms)
110001000042
210001000039
310001000041
410001000039
510001000038

十轮全部零丢失。而修复之前,性能不仅更差(因为频繁锁等待),数据丢失率还高得吓人。也就是说,减少提交次数之后,性能不但没有恶化,反而因为减少了大量锁等待和无效落盘,总体耗时降下来了。修复之后我又叠加跑了“100线程并发”的极限测试,丢数据的现象再也没有出现过。

这个整改还有一个附加收益:因为我顺手加了全局写锁,写入操作的代码路径变成了一个串行队列,之后排查任何写入相关的问题都会非常简单——你只需要面对一个干净的顺序模型,而不是一团乱麻的并发竞争。

5. 修复之外我必须承认的新边界:缓存一致性比想象中难

5.1 缓存层的二次踩坑:数据在内存里,但磁盘上是另一套

上一章最后,我以为事情结束了。DAY 3的下午我甚至在计划第二天要做什么功能了,结果一个随手加的集成测试又把问题引向了一个新方向。

那个测试是这样的:先通过业务接口写入一张卡片,再从业务接口读出来,校验内容和写入时一致。第一次跑就挂了。写进去的数据,通过缓存层读出来的没有问题,但一旦缓存过期(我在测试里把过期时间设置为一秒),再读出来的就是旧数据,甚至直接不存在。

原因是我的缓存层没有做"写失效"处理。缓存里存的是旧版本,数据库里已经写入了新版本,但缓存层完全不知道。这其实是非常经典的缓存一致性问题。做法也简单:每次数据更新之后,主动把对应缓存项删掉或更新,而不是依赖缓存自动过期。这个改动看起来不起眼,但实际价值很大。缓存失效如果做不好,遇到数据不刷新,用户第一反应一定是"存储坏了",永远意识不到是缓存的问题。

5.2 一条值得抄走的经验:先统一数据流再谈优化

这次DAY 3给我最深刻的经验,其实不是某个具体的API怎么用,而是一个架构层面的认知:一个系统的数据流必须明确到"写入只有一条路径、读取只有一条路径"。

所谓写入只有一条路径,意思是所有写入请求最终都会经过同一个调度者,哪怕底层用了十个连接也不能各写各的。读取只有一条路径,意思是读请求要么走缓存要么走数据库,不能一会儿走缓存、一会儿直查库、一会儿又绕过缓存写了一笔新的。我这个项目里,最初的问题正是因为写入路径有四条线程各写各的,读取路径又有缓存在中间搅局,整个数据流从入口到磁盘完全是乱的。

DAY 3原计划是写业务逻辑,结果一整天耗在了数据层,看起来是"进度延期"了,但第二天就可以直接开始写业务逻辑而不用回头改数据层。如果当初偷懒直接用JSON文件存数据,后面做搜索、做标签、做并发,每一步都要回头重写数据层,那才叫真正的延期。还有一个临别心得:这个"数据消失"的问题,在低并发验证下永远测不出来,所以功能开发过程中最好从第二天起就顺手跑一跑并发测试,哪怕只是最简单的一个多线程脚本,也都比你往后完全交付前才想起来测并发要少遭罪得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 6:21:07

彻底搞懂DataGrip报错:Connection refused to host 127.0.0.1排查指南

1. 先说结论:这条报错到底在告诉你什么我用DataGrip连本地数据库的时候,最常被问到的问题之一就是这个:Connection refused to host: 127.0.0.1。乍一看像是DataGrip本身出了问题,实际上它只是在传话——它把连接请求发到了127.0.…

作者头像 李华
网站建设 2026/10/21 6:21:07

知识工作插件化架构实战:契约设计与流水线搭建

1. 这个插件仓库到底解决了什么问题第一次看到knowledge-work-plugins这个仓库名的时候,我下意识以为又是一个"把常用函数打包一下"的工具集。点进去翻了翻目录结构和文档,才发现它的定位比我想的要精准得多——它瞄准的是知识工作者的重复性操…

作者头像 李华
网站建设 2026/10/21 6:21:06

OpenClaw 排错指南:从启动失败到 API 调用异常的 10 分钟诊断流程

如果你亲手部署过那些“README 五分钟、排错五小时”的开源项目,一定懂我在说什么。OpenClaw 就是这样一个脾气并不算好的本地自动化助手框架:配置里少一个冒号,启动阶段直接给你红屏;API Key 里多个空格,运行期分分钟…

作者头像 李华
网站建设 2026/10/21 6:21:06

通达信分时成交量抓涨停副图指标:源码拆解与实战应用

简介:通达信分时成交量见抓涨停指标公式源码是一份面向短线交易者的通达信副图公式文档,重点解决分时量能识别与涨停启动信号捕捉问题。文档给出PFFIN流通盘换算的真实换手率、VOL量柱、均量比与昨量比进度条、OBV累计能量等核心算法,并通过S…

作者头像 李华
网站建设 2026/10/21 6:20:58

Spring Boot游泳馆预约管理系统:防超卖与并发控制实战解析

游泳馆预约管理系统这种题目,在计算机毕设里属于典型的“看着简单,做起来全是细节”的类型。很多同学拿到题目第一反应是“不就是个增删改查吗”,真动手才发现,光一个“同一泳道同一时段不能被两个人同时约上”就能折腾掉好几天。…

作者头像 李华
网站建设 2026/10/21 6:20:26

从零搭建技能管理体系:评估、更新与落地实操指南

1. 从“skills”这个词说起:它到底指什么“skills”这个词看起来简单,但放在不同语境里,含义差别很大。有人看到它想到的是简历上那一栏“专业技能”,有人想到的是游戏里的技能树,有人想到的是团队协作中的能力矩阵&am…

作者头像 李华