ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多数据源查询引擎DQL-2实战:架构设计、连接器适配与性能优化

多数据源查询引擎DQL-2实战:架构设计、连接器适配与性能优化 DQL-2是我近半年投入时间最多的一个项目。起因特别简单团队内部原本用来查日志、查报表、查业务库的分散脚本越来越多每个人维护自己那一套连接方式参数不统一结果口径也经常对不上。DQL-1其实就是一个内部封装的查询脚本库能把几个常用数据源连起来跑SQL但到了去年下半年SQL类型一多、数据源一杂之后它频繁需要改配置、加分支整个代码开始变成一锅粥。我决定启动DQL-2的时候给自己定的目标很明确让查询这件事回到最朴素的样子——输入一条查询语句拿回一张表剩下的复杂度留在内部解决。这篇文章会完整讲一遍DQL-2的设计思路、核心链条、几个关键取舍以及实际调试过程中踩进去的坑。如果你也要做类似的多数据源查询工具或者正在犹豫要不要重写某个内部组件我相信其中有些经验可以参考。1. 从DQL到DQL-2一次被真实痛点逼出来的重构1.1 第一版只解决了一个“立刻能用”的需求DQL-1的代码规模并不大核心也就一千多行。它做的事情可以概括成三件解析一段简化的查询文本支持SELECT/WHERE/ORDER BY不支持JOIN根据配置里的数据源名称去连库执行查询拼成列表把结果导出成CSV或JSON这在一开始非常管用。早期团队只有两个数据源一个MySQL业务库一个Elasticsearch日志索引。需要查用户的付款记录就写一条类似SELECT id, amount, created_at FROM orders WHERE user_id 12345的语句需要查某个时段请求日志就用简化语法去ES索引里筛选并取top N条。这些操作原本要在Navicat和Kibana两个工具之间反复横跳DQL-1把流程缩短到一条命令。我在这版里犯的最大错误是没有预留数据源的抽象层。所有查询逻辑都和“怎么连MySQL”“怎么连ES”耦合在一起把数据源名称当作字符串在代码里传然后靠一串if-else去判断该走哪个连接器。后来加PostgreSQL时还好等要加ClickHouse和Redis的时候代码基本改不动了。1.2 让我决定推倒重来的三个信号真正促使我启动DQL-2的不是代码难看而是三个年里反复出现的问题。第一个是配置的“全局污染”。某一次为了给大数据组的同事临时放开一个超时参数我在配置中心里改了一个default_timeout的值结果第二天定时报表全部超时。原因是DQL-1所有数据源共用一个默认超时根本没有按连接区分的逻辑。这个问题虽然只发生了一次但排查耗掉了一个下午。第二个问题是查询结果没有统一的分页和类型模型。MySQL返回的是int、datetime、decimalES返回的是字符串和嵌套结构ClickHouse又有数组和低基数类型。我在结果导出层写了大量类型判断每当新增一种数据源JSON序列化逻辑就要重新适配。到后期连“查询耗时”这个指标都统计不准因为它只统计了执行时间没算网络传输和结果序列化时间。第三个问题也是最大的问题——没有可观测性。用户写了一条慢查询我只能靠猜测判断瓶颈是网络、解析还是数据源本身。全链路没有任何trace点没有日志分段。DQL-1完全建立在“自己用自己爽”的假设之上而实际上到了年底DQL-1已经有二十多个内部用户早就不再是个人脚本了。1.3 定义DQL-2的核心目标DQL-2立项时我把需求收敛成三条主线多数据源统一建模外部只面对一种查询语言和一种结果结构新增数据源不需要改核心引擎只需要实现一个连接器接口全链路可观测任何一条查询都能定位到等待网络、执行SQL、序列化结果的耗时这三条主线决定了后面所有设计。如果你也要重写一个类似工具我建议先别急着写代码把目标定义到“可以验证”的程度。比如我当时给自己定的验收条件是加一种新数据源的工作量不得超过两天且不能改动引擎代码。基于这些目标我给DQL-2定义一个明确的边界它是一个查询编排工具不是又一种SQL数据库。它不负责存储不负责事务不负责权限的细粒度控制只负责把查询语句转化成目标数据源能执行的查询并把结果统一成行集。2. 查询引擎核心链路一条查询语句在DQL-2里的完整旅程2.1 从文本到可执行计划的四个阶段DQL-2的查询引擎按我前几年做编译器的经验拆成了解析Parse、绑定Bind、规划Plan、执行Execute四个阶段。很多做工具的朋友会觉得这个设计太重但实际跑下来收益很大尤其是绑定阶段它把很多错误提前到了查询开始前而不是数据返回后。解析阶段把查询文本转成AST解析阶段接收用户输入的查询语句先按词法拆成token再按语法规则构造成AST抽象语法树。这里我直接选了基于PEG的解析器生成器没有手写递归下降因为查询语法会持续演进手写解析器维护成本太高。AST的节点类型包括SelectStmt、WhereClause、OrderByItem、LimitClause。这一阶段不做任何字段校验。用户在DQL-2写SELECT id FROM users WHERE age 30解析后得到一棵结构树其中id、users、age都是字符串字面量还没有关联到真实表结构。绑定阶段校验表、字段和类型绑定阶段是DQL-2最核心的安全网。我在这里维护了一份元数据缓存记录每个数据源的库名、表名、字段名、字段类型。一条查询进入绑定阶段后引擎会做三件事校验表是否存在、数据源是否可连通校验SELECT出来的字段名是否存在以及WHERE条件里的比较是否类型匹配根据字段类型推断返回列的类型比如说查询条件是WHERE create_time 20240101如果create_time是datetime类型而20240101是int类型绑定阶段会报警并尝试做常量类型转换转不了就直接报错避免把脏数据丢给执行层。这一步还顺带解决了DQL-1最头疼的空结果问题。以前执行一条查询如果返回空列表用户根本无法区分是“表里确实没数据”还是“查询条件拼错了”。绑定阶段如果发现FROM子句引用的表根本不存在会直接返回表不存在的错误。规划阶段决定去哪张表、怎么查规划阶段回答的问题是这条查询要怎么转换成各数据源的能力。DQL-2不追求生成最优物理执行计划毕竟我们不是做数据库但必须做正确的最小转换。具体做什么取决于目标数据源的能力边界。比如MySQL支持LIMIT和完整聚合但有些老版本不支持某些窗口函数Elasticsearch本身没有SQL执行能力我就定义了一个SearchSource接口把WHERE中的等值条件、范围条件翻译成DSL的过滤上下文ClickHouse支持大表扫描但对单行点查并不擅长规划阶段会把它标记为擅长的扫描型查询。规划阶段的输出是一棵执行计划树每个节点带上了目标数据源的连接器标识、预计返回行数、下推条件集合。我后来给每个计划节点加了一个cost_estimate字段用于做并发选择和超时控制。执行阶段连接复用、流式读取与错误兜底执行阶段是DQL-2里最接近业务逻辑的一环。这里着重解决了两个问题。第一是连接复用。DQL-2内部维护了一个连接池池化连接而不是每次查询都新建连接。连接池按data_source_name user维度分片每个分片默认保持2~5个连接空闲超过5分钟才回收。这一项改动直接让高频小查询的耗时从几十毫秒降到个位数毫秒。第二是流式读取。对大数据量结果DQL-2强制使用游标方式读取每次从数据库取500行放入结果缓冲同时开始序列化输出。这避免了以前一次性把10万行都load进内存然后爆OOM的问题。2.2 四层设计带来的维护红利很多朋友会问一个查询工具而已搞四层是不是过度设计我实测下来的感受是前面的成本主要花在写AST结构和绑定器上但收益在后续改需求时非常明显。比如后来要支持多语句顺序执行用分号分隔两条查询依次执行我只需要在解析层加一个MultiStmt节点在执行层加一个循环而绑定和规划完全不用动。再比如后来要加一个隐藏列_source_name我只需要在绑定阶段向AST里注入一个虚拟字段底层连接器完全无感知。如果你正在设计类似工具我的建议是解析和绑定这两层尽量不要省。你可以不做复杂的查询优化器不做成本估算但一定要把“查询合法性校验”和“数据源执行细节”这两件事分离开。真正让代码变乱的往往不是执行逻辑而是错误分散在各层处理。3. 连接器适配层怎么把MySQL、ClickHouse、ES的差异藏起来3.1 连接器接口的定义DQL-2的适配层是全项目的关键也是最容易越写越脏的地方。为了管住复杂度我定义了一个统一连接器接口所有数据源都按这个接口实现五个方法方法名作用说明Capabilities()能力声明是否支持分页、是否支持聚合下推、是否需要流式读取Schemas()元数据读取返回库表字段列表用于绑定阶段校验BuildPlan(ast)查询转换把规划层下发的逻辑计划转成数据源原生查询Execute(plan)执行查询接收转换后的查询返回迭代器Close()关闭资源释放游标、返回连接池这里有个关键点我把BuildPlan和Execute分开而不是合成一个Query()方法是因为像ES这种数据源BuildPlan出来的DSL以后还可以做缓存而Execute关心的是网络连接和游标生命周期两者生命周期不同分开更合适。3.2 谓词下推该在哪里过滤数据谓词下推是适配层里最常见的优化。所谓谓词就是WHERE条件里的那些过滤表达式。下推的意思是把过滤条件尽可能靠近数据源去执行而不是把全量数据拉到DQL-2内存里再过滤。MySQL和PostgreSQL原生支持SQL下推很自然就是把WHERE原样写进SQL。ClickHouse也很支持但我做了一点额外的工作DQL-2识别到查询条件是WHERE event_date today()这类带函数的表达式时会尝试在连接器里提前把函数计算结果算出来再下推一个常量条件避免ClickHouse每行都执行函数计算。ES的情况比较特殊。DQL-2对ES的WHERE条件转换有一套固定规则、IN转成term/terms、、BETWEEN转成rangeLIKE转成wildcard但只允许出现在字段的keyword类型上多个条件用AND连接对应DSL的must数组如果一个WHERE条件无法翻译成DSL中的任何一项我不会偷偷在内存里过滤而是直接返回一个明确错误不支持下推。这个设计看起来有点死板但总比用户以为查询走了ES、实际却把几万条文档全拉到内存过滤要强。可观测性里最怕的就是连真实执行路径都说不清楚。3.3 列类型统一没有类型系统的适配层都是耍流氓DQL-2在结果层面定义了一套自己的类型系统。总共只有10种类型Int、Float、Decimal、String、Bool、Date、DateTime、Timestamp、Array、Null。所有连接器返回数据时都必须把自己的原生类型映射到这套类型上。这个设计的直接好处是查询结果统一了。以前同一个时间字段MySQL返回datetime对象ClickHouse返回字符串ES返回毫秒时间戳前端展示和导出根本没法统一。现在连接器内部做转换DQL-2对外永远是一个带类型的行集。类型转换里最值得注意的坑是时区。MySQL的datetime本身不携带时区ES的时间戳是UTC毫秒值ClickHouse的DateTime默认按服务器时区解释。为了防止同一份数据在不同数据源里查出不一样的“昨天”我统一了规则DQL-2内部全链路使用UTC时间戳传输只在最终展示层由前端按用户时区格式化。连接器在读取MySQL时把datetime当成UTC时间直接转成Unix时间戳读取ClickHouse时先按服务器时区转成UTC再转成时间戳。这个问题在DQL-1里完全没管过导致当时很多查询结果相差8小时排查了一个多星期才发现是时区解释不一致。DQL-2从第一天就在类型系统层面把时区行为锁死后来的问题少了很多。3.4 分页语义不一致的妥协方案这些库的分页语义差异非常大。MySQL可以用LIMIT offset, size但offset太深时性能极差ClickHouse的分页和MySQL一致也存在深分页性能问题ES的from/size默认最大10000超过就要用search_after。DQL-2没有追求完全统一分页语法而是做了两个选择第一对外只暴露PAGE size100page1这种参数不暴露offset。第二连接器内部根据数据源能力自动选择分页策略。MySQL和ClickHouse直接计算offsetES在超过10000时自动改成search_after模式对用户透明。但search_after模式有一个限制它要求排序字段必须唯一且稳定。因此DQL-2规定对ES做深分页时必须显式按_id升序排列。如果用户排序条件里没有_id连接器会返回一个错误说明要求加上这个字段。虽然不完美但这种“有边界的妥协”让查询行为完全可以预期。4. 性能优化实录从十秒级到毫秒级的三个关键调整4.1 第一条慢查询为什么查100条数据要5秒有一次用户反馈DQL-2查一个只有100条数据的MySQL表响应时间却超过5秒。我开始以为是网络问题后来用EXPLAIN ANALYZE一查发现问题出在连接初始化阶段。查看日志后发现这100条数据全部卡在连接器Execute之前。原因是连接池在初始化时默认建立了8个连接而这个过程里有一个串行的认证握手。更离谱的是连接池的一个配置错误导致连接校验开关一直被打开每次查询都要先执行一次SELECT 1来检查连接是否存活这把连接复用的收益全抵消了。修复方式很直接把连接存活校验从“每次查询执行”改成“每30秒最多执行一次”把连接池初始化策略从“一次性建立8个连接”改成“懒加载预热到2个连接”改动后同样的查询耗时降到100毫秒以内。这件事的教训比较深刻性能问题不一定是查询复杂很多时候是框架自身的调用链太重。做工具的第一原则是别打扰用户连接池这种基础组件如果配置错了后面所有优化都白搭。4.2 类型推断导致索引失效第二条慢查询就更隐蔽了。用户表里的主键是字符串类型的ID查询条件是WHERE id IN (12345, 23456, ...)看起来ID是数字但实际上表结构定义的是varchar(32)。DQL-2的绑定阶段看到IN (12345, ...)全是数字字面量就把条件推断为Int类型然后直接下推成id IN (12345, ...)。MySQL收到这个SQL后把varchar字段和数字常量比较时会自动把字段转成数字类型导致字段上的索引失效执行计划变成了全表扫描。这属于绑定阶段的类型校验太宽松。我的修法是在绑定阶段增加一条规则用户查询里的常量类型先和目标字段类型做兼容性检查不一致时优先把常量转成目标字段类型而不是反过来。如果无法转换直接报错提醒用户加了引号。这个修复也影响了DQL-2对外的一个小特质它现在会尽量显示查询条件和字段类型的匹配情况而不是让数据库在底层做隐式转换。省去了一大类“为什么我加了索引还是慢”的问题。4.3 缓存策略为什么我最后放弃了查询结果缓存DQL-2最初版本有一个结果缓存模块目的很简单相同的查询语句在短时间内重复执行时直接返回上次结果省去数据库压力。实际用下来这个模块给我造成的麻烦比收益大得多。麻烦之一是缓存失效的粒度很难定。用户执行了DELETE FROM users WHERE id1然后立刻执行SELECT * FROM users WHERE id1缓存返回的却是删除前的旧数据。为了规避这个问题至少要按数据源维度监听写操作而很多数据源根本不提供简单的变更通知。麻烦之二是缓存空间和序列化成本。为了存缓存我必须对结果集做一次完整序列化但这个序列化本身和查询执行时间已经相当接近缓存带来的收益在慢查询上并不明显在快查询上则完全是负优化。最终我做出的决定很干脆DQL-2移除结果缓存只保留元数据缓存和连接池缓存。所有查询永远真实下发到数据源。这个决定看似是“性能倒退”但实际换来了行为确定性。查询工具最重要的价值是结果可信先确保这一点再去优化速度。尤其对内部工具来说“查出来的数据是不是最新”比“查询快不快”重要得多。4.4 并发控制别让一个慢查询拖垮整个服务DQL-2支持多用户同时查询但不同数据源的能力边界完全不同。有的MySQL库连接上限是50有的ClickHouse集群可以承受200个并发查询还有的ES集群在没有限流时会被一组大查询打挂。我在周末做了一轮压测之后发现DQL-2如果不加并发控制6个并发的全表扫描就可能让一个中型ClickHouse集群的CPU冲到80%以上。所以我给每个数据源增加了独立的并发配额数据源最大并发查询数单查询最大返回行数连接空闲超时MySQL业务库10500005分钟ClickHouse分析库201000003分钟Elasticsearch日志30200005分钟当一个连接器的活动查询数达到配额上限时新的查询会直接进入等待队列而不是继续压到数据源上。之前没有这个设计的时候一次业务方的临时分析任务就能把主库的慢日志刷屏。5. 排查出来的那些深坑三条完整的定位链路5.1 查询结果差8小时时区问题的复现排查第一次在DQL-2里碰到时区问题是测试同学报了一个单子从PostgreSQL查出来的created_at显示时间和数据库里的原始时间差8小时。我一开始怀疑是PostgreSQL驱动时区设置问题但查了连接串确认serverTimezoneUTC已经配置了。后来我从排查链路一步一步推才发现问题出在DQL-2自己的时间类型转换层。PostgreSQL驱动返回的是一个OffsetDateTime对象我为了统一格式把它转成Instant再用UTC的DateTimeFormatter格式化成字符串。这个思路本身没错但问题出在PostgreSQL的timestamp类型有两种timestamp without time zone和timestamp with time zone。前者不携带时区驱动返回的OffsetDateTime会带上JVM默认时区。当时DQL-2所在的服务器的JVM默认时区是Asia/Shanghai导致一个没有时区的timestamp被当作东八区转成了UTC等一下这里就有偏差了。最终修复是在连接器层面做判断如果PostgreSQL字段类型是timestamp without time zone直接把它按UTC时间处理不做JVM时区转换如果是timestamptz才按真实时区转成UTC。排查完这个问题的最大感受是统一时区不能靠调用方自觉必须在连接器边界就定死规则。5.2 连接泄漏压测时连接数缓慢上升的完整定位过程DQL-2上线第三周监控面板上MySQL连接数在稳步上涨平均每小时多出3~5个连接重启后又会恢复然后继续涨。刚开始我以为是连接池没有归还连接查了一圈连接池的监控指标borrowed和returned数量是对得上的。后来把连接数的增长和具体查询关联起来发现增长时段总是对应一批使用游标分页的报表任务。进一步看代码定位到问题出在连接器Close()方法的一个早期返回分支。当时Execute返回的迭代器允许用户在读取到一半时主动停止比如只取前100行然后break但为了快速响应我在迭代器关闭逻辑里写了一个早退路径func (iter *CursorIterator) Close() { if iter.cancelled { return } iter.connPool.Release(iter.conn) }这个代码的逻辑反了。cancelled为true时说明用户提前退出连接池的连接还没回收而这里直接return等于连接对象被丢弃且没有归还连接池。每个提前终止的查询泄漏一条连接时间一长连接数自然缓慢上升。修复后的代码在所有退出路径上都执行Release并且把连接归还写进defer作为兜底。以后写资源释放逻辑我建议一律把清理操作放在单一出口或defer里万一路径多了很容易漏。5.3 WHERE条件里有NULL一个让慢查询在计划阶段就爆掉的bug还有一个问题比较特殊。用户在查询里写了WHERE remark NULL本意是想找备注为空的数据。MySQL里这个写法永远查不到数据正确写法应该是IS NULL。DQL-2的绑定阶段没有拦截这种语义问题而是直接把它原样转成了SQL下发给MySQL。MySQL的优化器遇到 NULL时会把条件判定为永远假返回空结果耗时很短看起来没什么问题。但ClickHouse不一样。ClickHouse在某些版本中允许把 NULL作为可空字段的等值匹配来执行结果和MySQL的语义完全相反。同样一条查询在两个数据源里得出相反的结果这个才是真正危险的。处理方式是在绑定阶段加入常量NULL的类型检查如果比较运算符是或!而一侧是NULL常量直接报错引导用户写IS NULL或IS NOT NULL。修复之后同一套查询语义在不同数据源上得到一致的结果没有被任何一方的“方言”牵着走。6. 做完DQL-2之后我自己总结的几点经验先说说架构上的一个心得所有通用查询工具最后拼的都是类型系统、连接器边界和错误处理。SQL解析的能力大家都差不多真正让工具难用的是细节层面的语义不一致。时区、NULL、大小写、隐式转换、深分页这些看起来小而碎的内容恰恰决定了工具能不能跨数据源稳定工作。如果你也在做一个类似的内部工具有几件事我会特别建议在一开始就做结果集统一类型系统不要在展示层再做转换每个数据源的超时、并发限制、最大返回行数独立配置绝不做全局默认值所有和下推相关的规则全部显式化不支持就报错连接生命周期统一管理所有清理路径单一出口关于是否要重写我也给一个相对保守的建议如果现有工具只是你自己用查询频率低数据源固定那重写的动力可能确实不足。但一旦使用者超过三个人开始出现“环境不同导致结果不同”的歧义或者一次配置改动影响所有人重写的价值就已经很明显。DQL-2的重写成本大概花了三周大部分时间在类型转换和连接器适配这些“不显眼”但必须正确的部分。项目做到现在DQL-2已经接入了五个数据源内部用户超过三十人。现在每新增一个数据源我基本只需要写连接器两天内能完成上线引擎代码已经很久没动了。这也证明了当初把核心引擎和适配层完全隔离的方向是对的。最后分享一个小技巧DQL-2启动时会在后台对所有已配置数据源做一次元数据同步和连通性检测一旦某个源挂了查询入口会直接灰掉并提示原因而不是让用户发完查询后才看到超时错误。这一步对内部工具的用户体验提升非常明显强烈推荐。
返回列表