那次遇到的问题,看上去很像一个典型的架构升级:手里有几百个 JSON 资产文件,后面还会不断增加,扫描、检索、跨文件关联都不算顺手。于是,一个很自然的念头冒出来了:是不是应该上数据库,给这些文件建一套索引?
这个念头之所以有吸引力,是因为数据库听起来像一个“正经解决方案”。文件多了,就建表;搜索慢了,就加索引;以后规模再大,也好像提前铺好了路。但我后来发现,真正需要解决的并不是“有没有数据库”,而是当前读取到底慢在哪里,哪些查询确实值得增加一层长期维护的复杂度。
先确认问题到底有多大
当时我没有直接开始设计表结构,而是先回头核对几件很基础的事:资产文件究竟有多少,消费者现在是怎样读取它们,已有的索引模块到底有几个。计划里的规模数字只能作为线索,不能直接当作测量结果。因为文件数量、单个文件大小、读取频率和查询形态,任何一项不同,结论都会变。
比如,几百个文件听起来已经不少,但如果每次读取只做一次简单解析,而且运行环境的磁盘和内存都足够,那么“几百个”并不自动等于“必须上数据库”。反过来,如果消费者反复读取同一批文件,或者每次都要做跨文件匹配,即使总量不大,也可能存在真正的结构性问题。
规模是做架构决定的输入,不是替架构决定的结论。
这一步看似没有产出代码,却改变了后面的讨论。我们不再围绕“数据库该选哪一种”展开,而是先问:当前基线是什么,目标是什么,差距是否值得引入新的状态和维护链路。没有这个基线,任何性能承诺都只是感觉。
中文搜索会改变索引方案
核对读取方式之后,问题又往前推进了一步:资产检索不只是按英文标识或完整字段匹配,还需要处理中文查询。这里最容易误判的是,把“支持全文搜索”当成一个通用能力,以为默认的全文检索就能自然处理中文。
实际上,默认的全文检索分词方式对中文子串并不可靠。一个中文短语可能不会被拆成我们期待的词,用户输入其中一部分时,检索结果就可能悄悄为空。最危险的地方在于,它通常不报错,系统看起来运行正常,只有真实使用时才发现“明明有这条资产,怎么搜不到”。
所以方案后来没有停留在“加一个全文索引”这句话上,而是改为考虑 trigram,并补上中文黄金测试。测试不能只写英文关键词和完整名称,至少要覆盖中文子串、混合文本以及没有命中的情况。只有这样,索引方案才是在回答真实查询,而不是在满足一个技术名词。
如果不处理这一点,后果不只是少返回几条结果。用户会把“搜不到”理解成资产不存在,接着重复创建、重复整理,或者改用更重的人工目录。看起来是检索体验的小瑕疵,最后却会变成数据重复和工作流分叉。
给架构加一个退出条款
另一个重要变化,是没有把数据库开关设计成一次性的迁移按钮,而是明确分成 off、shadow、on 3 种状态。关闭时继续走原始 JSON;影子模式下可以建立并更新索引,但结果仍与原读取路径对照;打开后才让索引成为正式查询路径。
我选择这样做,是因为索引不是一段孤立的代码。它会带来新的文件、更新时机、并发关系和损坏恢复问题。如果直接改写所有消费者,任何一个边界情况都可能同时影响读取、写入和检索。到那时,即使知道问题出在索引,也未必能快速恢复原来的行为。
原始 JSON 必须保留,可重建的索引也必须保留清楚的生成路径。两者分层以后,索引是加速层,不是唯一事实来源。出现损坏、版本不一致或结果异常时,可以先关掉开关,回到原始数据,再重建索引,而不是带着怀疑继续在一份可能已经不完整的副本上修补。
一个值得上线的优化,必须同时拥有关闭它的办法和重新建立它的办法。
这也是退出条款的意义:如果全量读取已经达到目标,就不继续为了“架构完整”而推进迁移;如果索引在真实查询中没有带来可测收益,就不把它升级为所有消费者的必经路径;如果双读结果不一致,就停止扩大范围,先处理差异。
真实测量推翻了原来的方向
最后的实测比预想更有说服力:全量解析 493 个文件大约只需要 65 毫秒。这个数字直接改变了问题性质。原计划想用数据库解决扫描和检索的整体性能,但基线已经说明,扫描本身并没有超过目标。继续改写所有消费者,可能增加数据库连接、同步、并发和恢复成本,却没有换来相应的速度收益。
这件事让我重新意识到,很多架构争论并不是谁更懂技术,而是谁先把测量做出来。数据库并没有“错”,只是它要解决的问题在这里并不成立。真正有价值的部分,转而集中在中文检索和跨文件身份关联上:它们不是为了让一个已经足够快的扫描更快,而是为了让用户更容易找到正确资产,并知道不同文件之间是否指向同一个对象。
表面上我是在评估一个索引方案,其实我是在判断哪些复杂度值得被长期保存。性能目标既然已经满足,就没有理由为了一个听起来更专业的结构,替换掉简单、透明、可直接检查的原始读取路径。
当基线已经满足目标时,最专业的决定可能是放弃那次大改,只留下真正有收益的部分。
最容易误判的几个捷径
错误捷径之一,是因为数据文件“已经有几百个”,就直接把数量当成数据库的入场券。数量只说明需要测量,不说明必须迁移。更重要的是访问频率、查询类型、数据变化方式以及失败后的恢复成本。
错误捷径之二,是只做一次顺利查询,就宣布索引有效。顺利命中不能证明中文子串可靠,也不能证明重建、损坏恢复和双读比对没有问题。索引真正进入工作流以后,数据会变化,查询会变形,机器也会遇到中断。只测最理想的路径,测到的只是演示效果。
错误捷径之三,是把回滚理解成“出问题再删掉数据库”。这不算回滚,因为删掉之后可能没有可用的原始入口,也可能已经把消费者改到无法兼容的接口上。回滚应该是一个预先设计的状态切换:保留原始数据,保留开关,保留重建方式,并提前知道关闭后系统应该表现为什么样。
真正难的不是选一个更强的工具,而是承认一个看起来合理的方向可能没有收益。人很容易对已经投入的设计产生感情,尤其当设计涉及表、索引和迁移步骤时,工作量本身会制造一种“已经走了这么远”的错觉。可系统不是靠投入的时间证明方向正确,还是要回到用户实际查询和可接受的等待时间。
一套可以复用的判断流程
以后再遇到“要不要给资产目录建索引”的问题,我会把流程固定成几步。先核对真实规模、当前消费者和既有模块,再记录一次可重复的基线;接着列出真正的查询样本,特别是用户会输入的中文子串,而不是只拿技术上最方便的英文样例;然后写清性能目标和退出条款,提前规定什么情况下不迁移、暂停扩大或退回原路径。
如果确实需要索引,就把原始数据、索引数据和消费者分层,让索引可以重建,让消费者可以兼容。先用 shadow 模式做双读比对,检查中文黄金测试、损坏恢复和开关回滚,再考虑切到 on。每一步都要有证据:查询命中了什么,耗时是多少,结果是否一致,关闭后是否还能正常读取。
这个方法的重点不在于一定采用某种数据库,而在于让每个架构动作都回答 3 个问题:它解决了哪个已测量的问题?它增加了什么新的维护责任?如果它没有收益,能不能安全地退出?如果答不出来,继续加结构通常只是在延迟真正的判断。
一个流程成熟的标志,不是它能跑通一次,而是它能在下次少依赖临场记忆。把基线、黄金查询、开关状态和恢复方式留下来,下一次面对类似争论时,就不必重新被“听起来更专业”的方案带着走。
下次我会怎么做
下次再有人提出“文件多了,先上数据库吧”,我会先请他把当前读取和真实查询跑一遍。不是为了阻止升级,而是为了知道升级究竟要换来什么。如果全量解析仍然在目标以内,我会优先改善检索语义、跨文件关联和测试覆盖,而不是改写所有消费者。
如果测量确实显示需要索引,我会先把退出条款写出来,再设计开关和双读路径。原始数据不删除,索引不承担唯一事实来源,中文查询不靠想当然的分词,损坏和不一致都必须有回到旧路径的办法。
这件事让我更愿意把“先测基线”看成一种工作习惯,而不是一个性能技巧。它保护的并不只是几毫秒的速度,也保护系统的可理解性。很多时候,真正昂贵的不是一次扫描,而是为了没有被证明的问题,提前背上一整套长期维护的复杂度。