被骂了20年的N+1查询问题,为何在SQLite上不存在?
创始人
2026-06-22 11:08:48
0

事情的起因是SQLite自己惹的。

SQLite官网有一篇名为《SQLite的适用场景》的文档,里面提到了一件事:SQLite官方网站本身由Fossil版本控制系统驱动,而Fossil生成的每个动态页面——比如时间线、工单、Wiki——大约要执行200条SQL语句。

注意,这话是SQLite官方自己写在文档里的——竞争对手没挖,安全研究者没扒,SQLite自己大大方方摆在了台面上:我们一个页面,200条SQL。

然后读者先炸了。

“一个页面两百条SQL?这简直是糟糕透顶的设计。” “对大多数网站来说,两百条查询是太多太多太多了。” “N+1查询问题是教科书级别的反面教材,SQLite团队连这个都不知道吗?”

骂得有理有据,因为任何上过数据库优化课的人都能背出来:每条SQL就是一次网络往返,200次往返叠在一起,页面响应时间直奔天际。

整个推理链条完全正确,每一个环节都没有问题——只有一个前提被漏掉了:批评者们默认了一个他们甚至没有意识到的假设,即数据库一定跑在另一台服务器上。

SQLite的作者D. Richard Hipp看到这些评论时,大概是最平静的那个人,因为他知道这些愤怒的程序员在骂一个SQLite里根本不存在的概念。SQLite没有网络往返,从来不跟服务器通信,它就是一个嵌入在应用程序里的函数库,每一条”查询”本质上是一次C语言函数调用。

函数调用和网络往返之间的性能差距,大约是六个数量级。

但光讲道理是不够的,SQLite团队的回应方式比任何白皮书都更有杀伤力。

绝大多数数据库产品遇到性能争议时,标准公关流程是发一篇优化指南,教你如何把查询从两百条降到二十条,而SQLite的选择截然相反。他们从自己网站的一次真实页面渲染中抓取了完整的SQL执行日志——示意图、示例代码这些花活一概没有,直接上2016年9月16日那天,用户访问时间线页面时,数据库引擎实际执行的每一条SQL语句——然后把这份两百多行的日志全文贴了出来,附上一行淡定的标注:

“50条条目的时间线页面,总生成时间不到25毫秒。”

翻译成人话就是:你骂我之前,先把日志看完,看完了再告诉我问题出在哪儿。

“你确定问题出在查询数量上吗?”

一、N+1为什么是反模式——以及这个”为什么”比”是什么”重要一百倍

先理清概念。N+1查询问题,说的是应用程序先执行一条查询拉出一个列表(1次),再对列表里的每一项分别执行查询(N次),总共N+1次。典型场景:先查出50条时间线条目,再对每条条目分别查标签、权限、父节点。

在MySQL和PostgreSQL的世界里,这确实是要命的设计:应用程序跑在一台机器上,数据库跑在另一台机器上,每条SQL都要走一遍完整的TCP/IP协议栈——应用打包SQL→操作系统网络栈→网卡→交换机→数据库服务器网卡→操作系统→数据库引擎→计算结果→原路返回。这个流程每执行一次叫作一次”消息往返”(message round-trip),每一次往返,即使是本地网络,延迟也以毫秒计。

200次往返,哪怕每次只要1毫秒,光网络开销就是200毫秒——还没算上查询本身的计算时间,而用户感知到”这个网页有点慢”的阈值大约只有100毫秒。所以200条SQL的页面,在传统数据库架构下,确实是慢的,骂得对。

但问题就在这里。

SQLite既没有TCP/IP协议栈,也没有消息往返——应用程序调用SQLite的API时,SQLite的代码被直接编译进同一个进程,数据和索引躺在同一个文件系统的同一个文件里。一条查询的执行路径是:sqlite3_prepare_v2()→解析SQL→查B树索引→从磁盘读页面→返回结果,以上所有操作全部发生在当前进程的当前线程上,没有序列化,没有网络栈,没有上下文切换。

用Richard Hipp的原话说:”SQLite is not client/server. Queries do not involve message round-trips, only a function call.”

翻译成人话:你们骂的那个东西,SQLite里根本没有。

二、25毫秒,和它背后的代码架构账

SQLite官网由Fossil版本控制系统驱动,Fossil的每个动态页面——时间线、工单、Wiki——的SQL执行模式都是同一套逻辑。

先来一条大的,把所有时间线条目从数据库里拉出来的主查询,涉及多表关联、子查询、条件过滤:

这条查询同时证明了另一件事:SQLite完全有能力执行复杂的大查询——多表JOIN、嵌套子查询、GROUP_CONCAT聚合,MySQL能做的它都能做。如果你愿意用一条巨型SQL把整个页面需要的数据全部捞出来,SQLite不会有任何抱怨。

但Fossil偏不。

大查询跑完之后,Fossil对时间线上的每一个条目,又分别发出各自独立的小查询。比如对编号68028的条目,它依次查标签、查父节点链接、查权限:

SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68028;

SELECT pid FROM plink WHERE cid=68028 AND pid NOT IN phantom ORDER BY isprim DESC;

SELECT 1 FROM private WHERE rid=68028;

然后对编号68026的条目,再来一遍同样的查询,接着是68024、68018、68012……一共50个条目,每个至少3条小查询,加上页面首尾的配置查询和统计查询——整整齐齐,200条以上。

任何一个MySQL DBA看到这个日志都会心脏骤停。但页面底部赫然写着:生成耗时,少于25毫秒。

25毫秒。如果你的显示器是60Hz刷新率,这个时间够刷一帧半的画面。

更值得注意的细节是这份性能数据的进一步拆解:在这25毫秒里,真正花在SQLite数据库引擎内部的时间只占少数几毫秒,剩余消耗在HTTP处理、模板渲染、HTML输出上。那200多条SQL的查询开销,在整个页面的性能剖面图里甚至排不进前三。

把嘴巴里”200条一定慢”的肌肉记忆咽回去,你看到的事实是:当每条查询的开销从”一次洲际航班”降级为”在家里走两步”,200条查询就等价于200次从客厅走到厨房——谁会在意自己一天从客厅到厨房走了多少步?

三、有人在读教科书,有人在读教科书封面

到这一步,性能问题已经讲清楚了,但真正值得展开的戏肉在后面。

Fossil团队选择N+1模式,跟炫耀SQLite性能没有任何关系,他们的账算在了另一个方向上——代码维护成本

时间线页面展示的不止一种内容:提交记录(check-in)是一类,工单(ticket)是另一类,Wiki页面又是完全不同的一类,每一种类型的条目需要展示的信息不同、渲染逻辑不同、查询逻辑也不同。

提交记录需要查分支名、父节点链接、是否处于leaf位置;工单需要查关闭状态、处理人、优先级;Wiki页面需要查修订历史、最后编辑者、关联标签。

如果你把所有东西揉进一条巨型SQL里捞出来,你得到的是一条膨胀到几百行的JOIN语句,和一个把所有渲染分支塞在一起的巨型模板函数。提交人和工单处理人的列名在同一个结果集里打架,状态字段的语义互相污染。改一个工单显示逻辑,有可能震塌整个时间线的SQL结构。

N+1模式的反面,是在两套代码组织哲学之间做的一场取舍。把所有数据塞进一条查询,代码只有一块,但这块代码里装着三重交叉职责:数据获取、类型判断、格式渲染,谁也改不动,谁也不敢动。N+1模式把获取提交数据的代码放在提交渲染模块里,获取工单数据的代码放在工单渲染模块里,获取Wiki数据的代码放在Wiki渲染模块里。每种类型的开发人员只需要理解自己那块的查询逻辑,互不越界。

这是教科书级的关注点分离,只不过它恰好和另一本教科书(数据库性能优化)撞了车——两本教科书都对,但在SQLite的底盘上,关注点分离这本的分量更重。

这里有一个几乎在所有技术争论中都会复现的认知陷阱:把一门技术栈下形成的工程判断,当作所有技术栈下的物理定律。

N+1是反模式,因为网络往返昂贵;SQLite没有网络往返,所以N+1不是性能问题——这条推理链本身无懈可击。但在二十年的传播中,第一句话和第二句话之间的”因为”被磨掉了,只剩下一个光溜溜的结论:N+1在任何时候、任何场景、任何数据库上都是坏的。

四、迁移结论的人很多,迁移前提的人很少

这个剧本在技术界反复上演。

微服务拆分,原教旨是解决大单体在特定组织规模下的协调成本爆炸。传到后来变成了”微服务就是比单体好”,至于好在哪里、前提条件是什么,不重要了,拆就完了。容器化,原教旨是解决物理机时代的环境漂移和依赖地狱。传到后来变成了”容器化就是现代化”,一个十个人访问的内部工具也要上Kubernetes,不问你为什么,问就是最佳实践。

ORM的诞生,原始动机之一就是帮你自动合并N+1查询——Hibernate有@BatchSize,Entity Framework有Include(),Django ORM有select_related()和prefetch_related(),这些工具解决的是同一个问题:在客户端/服务器数据库上,查询数量太多会要命。但当数据库引擎变成SQLite——一个连进程边界都不跨的函数库——ORM里那些为了减少查询数量而引入的抽象层、批处理策略、预加载配置,从”必要的基础设施”变成了”不必要的复杂度”。

同样的现象在SQLite社区内部也有倒影。很多人第一次接触SQLite时的本能反应是”这个数据库太弱了,不适合生产环境”。他们的证据链是:SQLite不支持并发写入→高并发场景下MySQL更好→所以SQLite是玩具。

这个推理漏掉了一个关键事实:大多数网站和应用的实际写入并发量,远远低于开发者自以为的水平。SQLite的官方文档给出了一个让人沉默的数据:SQLite官方网站本身日均访问量约50万次动态页面请求,单台服务器承载,每个页面约200条SQL——正常运行,没有宕机,没有并发瓶颈

SQLite作者对这个问题的回应有一种外科手术式的精准:”SQLite does not compete with client/server databases. SQLite competes with fopen().”

SQLite的竞争对手不是MySQL,是文件读写函数——当你把复杂度基线从”一个分布式数据库集群”拉到”一次文件操作”的时候,200条SQL的性能负担露出了它的真实面目:接近于零。

很多人在这个点上停住了,觉得这不过是一个技术冷知识,但如果把视角拉高,会看到一个更大的图案。

软件工程的代际传承,存在一个结构性的信息衰减。第一代工程师在某个具体技术条件下发现了一个问题,设计了一个解决方案,写出了文档。第二代工程师读到了文档,记住了方案,但未必知道当初的问题长什么样。第三代工程师接手代码时,方案已经成了铁律,问题已经成了历史,前提条件已经沉入海底——水面上只剩下一条孤零零的戒律:N+1是不好的

把责任归结为某一代人偷懒,公平吗?恐怕不,这种衰减是知识在大规模传播中的必然宿命。每一代传下去的,是结论,是规则,是面试题的标准答案,但当初那个”为什么”——那个让规则之所以成为规则的具体场景、具体约束、具体成本结构——传着传着就散了。

SQLite的200条SQL恰好是一台完美的前提条件压力测试机,它对着所有面试过”N+1问题怎么解决”的开发者发出同一个灵魂拷问:你知道为什么N+1是问题吗?还是你只知道N+1是问题?

五、与其优化查询数量,不如理解查询成本

SQLite官网上的时间线页面至今仍然在运行,那个被骂了多年的架构每天都在生产流量面前正常工作。以下是生成一个典型时间线页面的完整SQL日志(2016年9月16日抓取)——从数据库打开到页面渲染结束,没有删减,没有”优化后”的表演版本:

把这份日志完整地摊在读者面前,是SQLite团队做过的最有底气的一件事——它比任何白皮书都更有说服力,因为它是未经剪辑的原始记录:没有隐藏的批量优化,没有事后补的缓存层,时间线上每一个你看得到的条目,背后都是逐条查询、逐条渲染。

如果批评者愿意花五分钟看完这份日志,至少有一半人会沉默,而剩下一半人可能会开始问另一个问题——

“如果200条SQL真的没问题,那我过去十年学的那套数据库优化理论,有多少条是放之四海皆准的,有多少条是只在特定技术栈上成立的?”

这个问题,比SQLite本身的性能数据更有价值。

软件开发最大的认知债务,是把工程判断当成物理定律。物理定律不依赖上下文——重力在MySQL上有效,在SQLite上也有效,在纸上推导时也有效;工程判断是一个特定上下文下的最优解,换一个上下文,同一个判断可能从最优解退化成过度设计。

200条SQL在MySQL上是性能灾难,200条SQL在SQLite上是完全不值得优化的背景噪声,两个结论同时成立、互相不矛盾,因为它们的判据来自同一个根源:查询开销到底长什么样。

网络往返的代价是毫秒级,函数调用的代价是纳秒级,差了一百万倍。当你要优化的开销存在于网络层,合并查询是正确策略;当网络层直接不参与这件事,合并查询就从”性能优化”退化为”为整洁而整洁”。

很多开发者的职业生涯中,真正理解了数据库内部原理的时刻,往往发生在这样一个瞬间:第一次看到SQLite那200条SQL的完整日志,在心里快速算了一下25毫秒的加载时间,然后突然意识到——过去花了大把精力给MySQL写的查询合并优化方案,放在SQLite上全是无用功。

那个瞬间,才算是真正读懂了N+1问题。

>>>>参考链接

  • 《SQLite的适用场景》

https://www.sqlite.org/whentouse.html#website

作者丨最后的绅士

来源丨网址:https://zhuanlan.zhihu.com/p/2044824229754013399?share_code=6oVxdvjyvEeJ&utm_psn=2045885026336780948

dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn

相关内容

热门资讯

“夏至不补,入伏受苦”!夏至后... 夏至到了,真的是高温天就来临了,以前我年纪小还不懂养生,觉得自己底子好,一过立夏,热了就吹空调、洗凉...
端午假期市属公园接待游122.... 新闻广播记者从北京市公园管理中心了解到,端午假期三天,市属公园共接待游客122.78万人次,游客量排...
24年,56岁女子吃维生素B6... 同型半胱氨酸这个名字听起来很绕,其实可以把它理解成血管里的“细小磨砂颗粒”。它不是血糖,也不是血脂,...
端午假期1400多万人次游四川... 6月20日,2026广安乡村跑举行,千名跑者沿田园果林赛道畅跑,以“体育+农旅”模式激活乡村假日活力...
突发!上市公司副总裁(女,19... 6月21日,思泉新材(301489)发布公告称,公司副总裁、董事会秘书郭智超女士于2026年6月19...
在伊犁的夏日草原,邂逅一场不愿... 在伊犁的夏日草原,邂逅一场不愿醒来的梦境 一段旅程的缘起 那年夏天,我站在那拉提草原的缓坡上,看着远...
胃反酸人群:早上这样养,烧心少... 很多人反酸反复、烧心难好,并不是药没吃对,而是 晨起养胃方式错了!早上是胃部修复的黄金时间,晨起正确...