被骂了20年的N+1查询问题,为何在SQLite上不存在?
事情的起因是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执行模式都是同一套逻辑。
先来一条大的,把所有时间线条目从数据库里拉出来的主查询,涉及多表关联、子查询、条件过滤:
INSERT OR IGNORE INTO timeline SELECTblob.rid AS blobRid,uuid AS uuid,datetime(event.mtime,toLocal()) AS timestamp,coalesce(ecomment, comment) AS comment,coalesce(euser, user) AS user,blob.rid IN leaf AS leaf,bgcolor AS bgColor,event.type AS eventType,(SELECT group_concat(substr(tagname,5), ', ') FROM tag, tagxrefWHERE tagname GLOB 'sym-*' AND tag.tagid=tagxref.tagidAND tagxref.rid=blob.rid AND tagxref.tagtype>0) AS tags,tagid AS tagid,brief AS brief,event.mtime AS mtimeFROM event CROSS JOIN blobWHERE blob.rid=event.objidAND NOT EXISTS(SELECT 1 FROM tagxref WHERE tagid=5 AND tagtype>0 AND rid=blob.rid)ORDER BY event.mtime DESC LIMIT 50;
这条查询同时证明了另一件事: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日抓取)——从数据库打开到页面渲染结束,没有删减,没有”优化后”的表演版本:
-- sqlite3_open: /home/drh/sqlite/sqlite/.fslckoutPRAGMA foreign_keys=OFF;SELECT sql FROM localdb.sqlite_schema WHERE name=='vfile';-- sqlite3_open: /home/drh/.fossilPRAGMA foreign_keys=OFF;SELECT value FROM vvar WHERE name='repository';ATTACH DATABASE '/home/drh/www/repos/sqlite.fossil' AS 'repository' KEY '';SELECT value FROM config WHERE name='allow-symlinks';SELECT value FROM global_config WHERE name='allow-symlinks';SELECT value FROM config WHERE name='aux-schema';SELECT 1 FROM config WHERE name='baseurl:http://';SELECT value FROM config WHERE name='ip-prefix-terms';SELECT value FROM global_config WHERE name='ip-prefix-terms';SELECT value FROM config WHERE name='localauth';SELECT value FROM vvar WHERE name='default-user';SELECT uid FROM user WHERE cap LIKE '%s%';SELECT login FROM user WHERE uid=1;SELECT cap FROM user WHERE login = 'nobody';SELECT cap FROM user WHERE login = 'anonymous';SELECT value FROM config WHERE name='public-pages';SELECT value FROM global_config WHERE name='public-pages';SELECT value FROM config WHERE name='header';SELECT value FROM config WHERE name='project-name';SELECT value FROM config WHERE name='th1-setup';SELECT value FROM global_config WHERE name='th1-setup';SELECT value FROM config WHERE name='redirect-to-https';SELECT value FROM global_config WHERE name='redirect-to-https';SELECT value FROM config WHERE name='index-page';SELECT mtime FROM config WHERE name='css';SELECT mtime FROM config WHERE name='logo-image';SELECT mtime FROM config WHERE name='background-image';CREATE TEMP TABLE IF NOT EXISTS timeline(rid INTEGER PRIMARY KEY,uuid TEXT,timestamp TEXT,comment TEXT,user TEXT,isleaf BOOLEAN,bgcolor TEXT,etype TEXT,taglist TEXT,tagid INTEGER,short TEXT,sortby REAL);INSERT OR IGNORE INTO timeline SELECTblob.rid AS blobRid,uuid AS uuid,datetime(event.mtime,toLocal()) AS timestamp,coalesce(ecomment, comment) AS comment,coalesce(euser, user) AS user,blob.rid IN leaf AS leaf,bgcolor AS bgColor,event.type AS eventType,(SELECT group_concat(substr(tagname,5), ', ') FROM tag, tagxrefWHERE tagname GLOB 'sym-*' AND tag.tagid=tagxref.tagidAND tagxref.rid=blob.rid AND tagxref.tagtype>0) AS tags,tagid AS tagid,brief AS brief,event.mtime AS mtimeFROM event CROSS JOIN blobWHERE blob.rid=event.objidAND NOT EXISTS(SELECT 1 FROM tagxref WHERE tagid=5 AND tagtype>0 AND rid=blob.rid)ORDER BY event.mtime DESC LIMIT 50;-- SELECT value FROM config WHERE name='timeline-utc';SELECT count(*) FROM timeline WHERE etype!='div';SELECT min(timestamp) FROM timeline;SELECT julianday('2016-09-15 14:54:51',fromLocal());SELECT EXISTS (SELECT 1 FROM event CROSS JOIN blob WHERE blob.rid=event.objid AND mtime<=2457647.121412037);SELECT max(timestamp) FROM timeline;SELECT julianday('2016-09-24 17:42:43',fromLocal());SELECT EXISTS (SELECT 1 FROM event CROSS JOIN blob WHERE blob.rid=event.objid AND mtime>=2457656.238009259);SELECT value FROM config WHERE name='search-ci';SELECT value FROM vvar WHERE name='checkout';SELECT value FROM config WHERE name='timeline-max-comment';SELECT value FROM global_config WHERE name='timeline-max-comment';SELECT value FROM config WHERE name='timeline-date-format';SELECT value FROM config WHERE name='timeline-truncate-at-blank';SELECT value FROM global_config WHERE name='timeline-truncate-at-blank';SELECT * FROM timeline ORDER BY sortby DESC;SELECT value FROM config WHERE name='hash-digits';SELECT value FROM global_config WHERE name='hash-digits';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 tagxref WHERE rid=68028 AND tagid=9 AND tagtype>0;SELECT value FROM config WHERE name='timeline-block-markup';SELECT value FROM config WHERE name='timeline-plaintext';SELECT value FROM config WHERE name='wiki-use-html';SELECT value FROM global_config WHERE name='wiki-use-html';SELECT 1 FROM private WHERE rid=68028;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68026;SELECT pid FROM plink WHERE cid=68026 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=68026;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68024;SELECT pid FROM plink WHERE cid=68024 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=68024;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68018;SELECT pid FROM plink WHERE cid=68018 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=68018;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68012;SELECT pid FROM plink WHERE cid=68012 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=68012;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68011;SELECT value FROM config WHERE name='details';SELECT pid FROM plink WHERE cid=68011 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM tagxref WHERE rid=68011 AND tagid=9 AND tagtype>0;SELECT 1 FROM private WHERE rid=68011;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68008;SELECT pid FROM plink WHERE cid=68008 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=68008;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68006;SELECT pid FROM plink WHERE cid=68006 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=68006;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=68000;SELECT pid FROM plink WHERE cid=68000 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=68000;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67997;SELECT pid FROM plink WHERE cid=67997 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67997;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67992;SELECT pid FROM plink WHERE cid=67992 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67992;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67990;SELECT pid FROM plink WHERE cid=67990 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67990;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67989;SELECT pid FROM plink WHERE cid=67989 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67989;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67984;SELECT pid FROM plink WHERE cid=67984 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67984;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67983;SELECT pid FROM plink WHERE cid=67983 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67983;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67979;SELECT pid FROM plink WHERE cid=67979 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67979;SELECT value FROM config WHERE name='ticket-closed-expr';SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='1ec41379c9c1e400' AND tkt_uuid<'1ec41379c9c1e401';SELECT 1 FROM private WHERE rid=67980;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67977;SELECT pid FROM plink WHERE cid=67977 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='1ec41379c9c1e400' AND tkt_uuid<'1ec41379c9c1e401';SELECT 1 FROM private WHERE rid=67977;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='1ec41379c9c1e400' AND tkt_uuid<'1ec41379c9c1e401';SELECT 1 FROM private WHERE rid=67974;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67971;SELECT pid FROM plink WHERE cid=67971 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67971;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67972;SELECT pid FROM plink WHERE cid=67972 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67972;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67969;SELECT pid FROM plink WHERE cid=67969 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67969;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67966;SELECT pid FROM plink WHERE cid=67966 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67966;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67962;SELECT pid FROM plink WHERE cid=67962 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67962;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67960;SELECT pid FROM plink WHERE cid=67960 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67960;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67957;SELECT pid FROM plink WHERE cid=67957 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67957;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67955;SELECT pid FROM plink WHERE cid=67955 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67955;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67953;SELECT pid FROM plink WHERE cid=67953 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='5990a1bdb4a073' AND tkt_uuid<'5990a1bdb4a074';SELECT 1 FROM blob WHERE uuid>='5990a1bdb4a073' AND uuid<'5990a1bdb4a074';SELECT 1 FROM private WHERE rid=67953;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67941;SELECT pid FROM plink WHERE cid=67941 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67941;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67940;SELECT pid FROM plink WHERE cid=67940 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67940;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67938;SELECT pid FROM plink WHERE cid=67938 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67938;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67935;SELECT pid FROM plink WHERE cid=67935 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67935;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67934;SELECT pid FROM plink WHERE cid=67934 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67934;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67932;SELECT pid FROM plink WHERE cid=67932 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67932;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67930;SELECT pid FROM plink WHERE cid=67930 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67930;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67928;SELECT pid FROM plink WHERE cid=67928 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM tagxref WHERE rid=67928 AND tagid=9 AND tagtype>0;SELECT 1 FROM private WHERE rid=67928;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='0eab1ac7591f511d' AND tkt_uuid<'0eab1ac7591f511e';SELECT 1 FROM private WHERE rid=67919;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='01874d252ac44861' AND tkt_uuid<'01874d252ac44862';SELECT 1 FROM blob WHERE uuid>='01874d252ac44861' AND uuid<'01874d252ac44862';SELECT 1 FROM private WHERE rid=67918;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67916;SELECT pid FROM plink WHERE cid=67916 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='0eab1ac759' AND tkt_uuid<'0eab1ac75:';SELECT 1 FROM private WHERE rid=67916;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='a49bc0a8244feb08' AND tkt_uuid<'a49bc0a8244feb09';SELECT 1 FROM blob WHERE uuid>='a49bc0a8244feb08' AND uuid<'a49bc0a8244feb09';SELECT 1 FROM private WHERE rid=67914;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67913;SELECT pid FROM plink WHERE cid=67913 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='0eab1ac7591f' AND tkt_uuid<'0eab1ac7591g';SELECT 1 FROM private WHERE rid=67913;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67911;SELECT pid FROM plink WHERE cid=67911 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67911;SELECT status='Closed' OR status='Fixed' FROM ticket WHERE tkt_uuid>='0eab1ac7591f511d' AND tkt_uuid<'0eab1ac7591f511e';SELECT 1 FROM private WHERE rid=67909;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67907;SELECT pid FROM plink WHERE cid=67907 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67907;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67899;SELECT pid FROM plink WHERE cid=67899 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67899;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67897;SELECT pid FROM plink WHERE cid=67897 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67897;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67895;SELECT pid FROM plink WHERE cid=67895 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67895;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67893;SELECT pid FROM plink WHERE cid=67893 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67893;SELECT value FROM tagxref WHERE tagid=8 AND tagtype>0 AND rid=67891;SELECT pid FROM plink WHERE cid=67891 AND pid NOT IN phantom ORDER BY isprim DESC;SELECT 1 FROM private WHERE rid=67891;SELECT count(*) FROM plinkWHERE pid=67928 AND isprimAND coalesce((SELECT value FROM tagxrefWHERE tagid=8 AND rid=plink.pid), 'trunk')=coalesce((SELECT value FROM tagxrefWHERE tagid=8 AND rid=plink.cid), 'trunk');SELECT count(*) FROM plinkWHERE pid=68011 AND isprimAND coalesce((SELECT value FROM tagxrefWHERE tagid=8 AND rid=plink.pid), 'trunk')=coalesce((SELECT value FROM tagxrefWHERE tagid=8 AND rid=plink.cid), 'trunk');SELECT count(*) FROM plinkWHERE pid=68028 AND isprimAND coalesce((SELECT value FROM tagxrefWHERE tagid=8 AND rid=plink.pid), 'trunk')=coalesce((SELECT value FROM tagxrefWHERE tagid=8 AND rid=plink.cid), 'trunk');SELECT value FROM config WHERE name='show-version-diffs';SELECT value FROM config WHERE name='adunit-omit-if-admin';SELECT value FROM global_config WHERE name='adunit-omit-if-admin';SELECT value FROM config WHERE name='adunit-omit-if-user';SELECT value FROM global_config WHERE name='adunit-omit-if-user';SELECT value FROM config WHERE name='adunit';SELECT value FROM global_config WHERE name='adunit';SELECT value FROM config WHERE name='auto-hyperlink-delay';SELECT value FROM global_config WHERE name='auto-hyperlink-delay';SELECT value FROM config WHERE name='footer';PRAGMA database_list;PRAGMA database_list;PRAGMA localdb.freelist_count;PRAGMA localdb.page_count;
把这份日志完整地摊在读者面前,是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