我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

NL2SQL 中默认 LIMIT 100:为什么是产品安全边界而不是数据库参数

NL2SQL 中默认 LIMIT 100:为什么是产品安全边界而不是数据库参数 最近在技术群里看到有人贴了一行报错截图codex exceeded retry limit, last status: 429 too many requests。底下的讨论惯例性滑向了 token 配额、重试退避、请求并发这些话题。我盯着这个 limit 看了半天想到的却是我们自己 NL2SQL 服务里另一条容易被人忽略的 limit——SQL 执行时默认加上的 LIMIT 100。写 NL2SQL 这一年多我们踩过最大的坑几乎都跟返回多少行有关。用户用大白话问一句给我看看 5 月 21 号的支付流水模型生成的 SQL 可能是SELECT * FROM payment_logs WHERE pay_date 2024-05-21这句 SQL 有 WHERE 条件看起来人畜无害但当天流水可能有 800 万行。如果不给这条 SQL 加行数上限轻则前端卡死重则把正在跑业务的库拖到连接池打满。今天想把这块儿彻底聊透默认 100 行这个数字是怎么来的LIMIT 到底该由哪一层来注入上线之后它替我们挡了哪些枪以及它背后真正代表的产品判断。这篇东西适合正在做 NL2SQL、AI BI 助手或者任何让大模型直接碰数据库方向的工程师参考。1. 默认 LIMIT 100 不是数据库参数是产品安全边界1.1 一场把核心接口拖到超时的线上事故先说让我下定决心做强制 LIMIT 的那次事故。内部 NL2SQL 平台刚上线的时候我们的策略比较简单大模型生成什么 SQL网关就执行什么 SQL只做了只读账号和基础黑名单。某天下午运营同学问了一句查一下 5 月 21 日所有支付流水的明细模型生成的 SQL 是SELECT * FROM payment_logs WHERE pay_date 2024-05-21;这句 SQL 看起来没有任何问题有过滤条件过滤字段 pay_date 还是普通索引字段。但问题在于当天这笔流水明细有八百多万行索引把数据捞出来之后要回表取所有字段主库 CPU 瞬间被打到 95% 以上业务连接池开始排队最终影响到线上交易查询接口页面端体验直接雪崩。事后复盘原因不是模型生成得不好而是我们压根没有定义一条查询到底最多能返回多少行也没有统计过这个默认值该是多少。那次事故之后我们给 NL2SQL 网关定了一条铁律SELECT 查询一律要带 LIMIT除非用户显式指定了行数。这就是默认 100 行的起点。1.2 为什么是 100而不是 10 或 10000定这个数字的时候我们内部列过三个维度的账。第一是人一次性能够消费多少行数据。我拿自己做实验打开一张订单列表表格一屏大概能舒服地看完 20 到 50 行再往下就要滚动了超过 100 行之后多数人的反应已经不是看数据而是我想筛选、我想分组、我想知道总数。第二是行宽和传输成本。像 payment_logs 这种宽表一行十来个字段有些字段还是几百字节的 JSON1000 行就是好几 MB前端渲染加网络传输体验不会好。第三是查询执行成本。LIMIT 不是万能的但它至少能把返回给客户端的行数控制住也能让很多模型生成的全表扫描由于只需取前 100 行而触发索引快速路径。合成一句话100 这个数字是我们从人能不能消费、网络能不能扛、数据库能不能顶三个约束里折出来的一个保守值。它不科学到可以精确推导但它足够安全而且之后可以通过配置动态调整。比 100 更重要的是当时我们意识到这个数字本质上是产品安全边界不是数据库参数。1.3 真正的根因LLM 生成 SQL 的不确定性如果查询都是 DBA 手写LIMIT 的事可能不会专门写成一篇博客。NL2SQL 的问题在于SQL 是大模型生成的而大模型的行为天然不稳定。同样一句每个城市的订单量不同模型可能生成 GROUP BY 也可能生成 GROUP_CONCAT可能带 LIMIT 也可能不带。今天测试通过的提示词换个版本可能就漏了以前温顺地加 LIMIT 的模型更新后突然开始大量写 SELECT *。所以 LIMIT 的保护不能寄希望于模型的自觉必须在执行链路上有一道强制兜底。这就是为什么 API 限流 429 和 SQL 行数 LIMIT 看起来都是limit但性质完全不同前者是保护大模型服务资源后者是保护用户的数据库和分析体验。做 NL2SQL 的人如果只关注 token 和重试次数忽略了生成出来的 SQL 要跑在真实数据库上迟早要吃大亏。2. LIMIT 注入的三种实现方案与各自的暗坑2.1 只靠提示词约束我坚持了半个月就放弃了第一版方案也是最省事的方案就是在 NL2SQL 的提示词末尾加一句Always add LIMIT 100 to every SELECT query unless the user explicitly asks for a specific number of rows. 当时测试了几十个常见问题模型表现确实不错基本都乖乖带上了 LIMIT 100。于是我们兴冲冲地上了生产。随后半个月问题开始暴露。有些开源模型在复杂查询里会把这条指令丢掉尤其当生成的 SQL 已经包含 JOIN、子查询和 CASE WHEN 的时候提示词后面的 LIMIT 要求往往被挤掉。还有一次我们升级了某个模型的版本同一套提示词加 LIMIT 的遵守率直接从 90% 掉到 60%。更隐蔽的问题是模型偶尔会自作聪明地生成 LIMIT 1000或者在前 100 行提示词里加过 LIMIT 之后又在子查询里重复加造成了各种奇怪的语义。结论很直接提示词适合用来引导模型但不能当成安全约束。约束必须走在 SQL 真正被执行之前。2.2 后端 SQL AST 改写目前的主方案我们最终采用的主方案是在网关层对模型生成的 SQL 做 AST 解析和改写。选用的是 sqlglot 这个 Python 库它对各种 SQL 方言支持得比较全解析失败的概率比较低。核心逻辑很简单from sqlglot import parse_one, exp def enforce_limit(sql: str, dialect: str mysql, default_limit: int 100) - str: ast parse_one(sql, readdialect) if ast.args.get(limit): return sql ast.set(limit, exp.Limit(expressionexp.Literal.number(default_limit))) return ast.sql(dialectdialect)这段代码只是骨架真正上线要考虑几个细节。第一只有最外层的 SELECT 需要注入 LIMIT子查询、WITH CTE 里的 SELECT 不要动否则会改变原本的语义。第二如果 SQL 本身是 INSERT INTO ... SELECT 或者 CREATE TABLE AS 这种写入型语句在我们的只读场景里干脆直接拒绝不让它进执行层。第三聚合查询产生的行数通常不大但也可能因为 GROUP BY 的高基数字段产生几千组所以统一也走 LIMIT 100。比较微妙的是用户自己带了 LIMIT 的情况。我们的策略是显式 LIMIT 小于等于默认值时完全尊重显式 LIMIT 大于默认值时交互式查询截断到默认值并给出结果已截断的提示同时引导用户使用导出功能。而不是简单粗暴地把用户的行数改成 100也不是无脑放行这个产品判断后面还会展开聊。2.3 网关与数据库侧的兜底最后一道防线AST 改写也不是万能的方言解析失败、SQL 语法太偏门、或者哪天引入一个新的执行通道忘了走同一套逻辑都可能让没有 LIMIT 的 SQL 漏过去。所以我们在数据库侧和连接层也做了兜底。最实用的三招只读账号权限收敛会话级超时时间资源队列隔离。拿 PostgreSQL 举例我们可以给执行 NL2SQL 的账号设置 statement_timeout防止任何一条查询无限制地跑下去ALTER ROLE nl2sql_reader SET statement_timeout 5s;MySQL 侧可以在连接池层面对执行时间做拦截或者把超时检查做在网关线程里。另外我们还在网关挡了一层很隐蔽的问题——OFFSET 过大。一条SELECT ... LIMIT 100 OFFSET 100000的查询虽然只返回 100 行但数据库为了跳过前十万行可能要把它们全部扫描一遍慢查询照样把资源吃满。我们的规则是 OFFSET 超过 10000 就直接拒绝并提示用户缩小范围或者改走异步任务。2.4 三种方案怎么选一张表说清楚把三种方式的优劣摆在一起看会比较直观方案优点缺点适合阶段提示词约束零开发成本改一行提示词就行行为随模型变化不可靠Demo、原型验证网关 AST 改写精准、可控、可审计能感知用户显式 LIMIT需要处理方言和语法兼容生产环境主方案数据库会话级兜底最后防线不依赖上层逻辑能力受数据库限制报错不够友好任何阶段都必须叠加我们的结论是三层都要有但主防线放在 AST 改写。提示词负责降低被兜底的次数数据库兜底负责兜住最坏情况。为什么不用应用层直接截断结果集比如查了 10000 行再只取前 100 行返回前端这是舍本逐末数据库已经把资源消耗完了应用层截断没有意义。3. 上线后的五个真实战场LIMIT 引发的认知偏差3.1 DISTINCT LIMIT用户以为全库只有 100 个商品上线强制 LIMIT 之后第一个用户工单来自商品运营团队。同事的原话是咱们系统问出来在售商品只有 100 个这数据是不是同步错了 我们查了一下用户的问题是有哪些商品在售模型生成的 SQL 是SELECT DISTINCT name FROM products WHERE status on_sale LIMIT 100;这个 SQL 本身没毛病但致命的是商品库实际有 3800 多个在售 SKU而模型没做任何提示直接把前 100 个返回给了用户。用户看到一行一行的商品名理所当然地以为这就是全部在售商品于是把我们只有 100 个商品当成事实汇报给了领导。这个场景本质上是截断信息和用户心智之间的冲突LIMIT 在技术上是对的在体验上却制造了谎言。我们的解法分两步。第一步在返回结果的元信息里加上 truncated 字段一旦返回行数等于上限前端就展示当前结果已截断仅展示前 100 条。第二步针对这种列举型查询我们在提示词里引导模型优先生成先查总数再让用户设置筛选条件的 SQL比如先执行SELECT COUNT(DISTINCT name) FROM products WHERE status on_sale再结合分页看明细。用户看到宏大的总数就不会把前 100 行当成世界的全部。3.2 聚合后恰好 100 行完整结果还是被截断第二种情况更隐蔽。用户问统计每个城市每个渠道的每日订单量时间范围取最近 7 天。模型生成SELECT city, channel, order_date, COUNT(*) AS order_cnt FROM orders WHERE order_date CURRENT_DATE - INTERVAL 7 DAY GROUP BY city, channel, order_date ORDER BY order_date, city, channel LIMIT 100;这个查询返回的结果恰好是 100 行。从技术上看一切正常但产品上出了大问题用户无法判断这 100 行到底是完整的 100 行还是被 LIMIT 硬生生截断的 100 行。如果这个城市 x 渠道 x 日期的组合恰好有 113 个分组后面 13 组就被静默丢掉了用户做出的业绩判断就是错的。排查这类问题关键是在执行前判断查询是不是聚合类查询。如果是 GROUP BY 或 DISTINCT 查询LIMIT 截断的不是原始行而是分组我们应该额外生成一条SELECT COUNT(*) FROM (原始分组查询)的 SQL 来拿到真实分组数或者至少给出一个估算值。实际落地时我们让模型对聚合查询先不着急加 LIMIT而是由网关判断是否注入同时对高基数的 GROUP BY 给出分组数超出限制的明确提示。3.3 用户自带 LIMIT 5系统再注入 LIMIT 100第三个坑出在用户显式 LIMIT和系统默认 LIMIT的交互上。我们最初实现 enforce_limit 时只判断了有没有 LIMIT有就放过。结果有个分析师的查询是SELECT * FROM ( SELECT * FROM order_items WHERE sku_id xxx ORDER BY create_time DESC LIMIT 5 ) t用户的真实意图是取最近 5 条订单明细外层没有 LIMIT网关一看外层没有 LIMIT就注入了默认 LIMIT 100。结果是不出错的外层就是 5 行LIMIT 100 没有任何影响但这条 SQL 给人的心理感受很怪而且它更容易触发执行计划的变化。更值得讨论的是另一种情况用户显式写了 LIMIT 3000想一次性看 3000 行。我们的策略是交互式查询仍然只放 100 行但明确提示结果已截断点击导出可获取完整数据。事后我们反思这里真正的产品判断是用户要 3000 行其实通常不是要在网页上滚动看 3000 行而是想要一份完整的数据。前者是展示问题后者是交付问题。展示问题和交付问题不能用同一个 LIMIT 解决。3.4 LIMIT 100 还是慢filesort 与 OFFSET 的真面目第四个战场在性能一侧。有一个用户反馈单表五亿行的订单表查询最新 100 条已完成订单一直转圈。生成的 SQL 是SELECT * FROM orders WHERE status finished ORDER BY order_time DESC LIMIT 100;表面看有 ORDER BY LIMIT 100应该很快但执行了三十二秒。我们用 EXPLAIN 查了一下走了 status 的二级索引之后因为要按 order_time 排序而 order_time 不在索引覆盖范围内优化器选择了 filesort 全量排序五亿行的排序直接吃爆了内存。这个例子里 LIMIT 100 限制了返回给客户端的数据量但没限制数据库内部为了找出这 100 行需要付出的计算量。排查链路走完我们做了三件事。一是给这类场景的提示词里加规则如果用户要最新的 N 条尽量用 order_time 索引配合 WHERE 条件缩小范围而不是裸排序。二是在网关层加上执行时间阈值5 秒以上的查询直接中断并提示用户收窄条件避免 DBA 半夜接到慢查询告警。三是在产品上引导用户真要分析五亿行的最新状态应该走聚合查询或者异步导出而不是让交互界面去啃明细。3.5 JOIN 行数膨胀100 行保护了性能却破坏了语义最后一个场景来自业务方的一个投诉你们的系统有问题同一个订单号重复出现好多行而且不全。 用户的问题是把订单和物流信息对起来看一眼模型生成的是SELECT o.order_id, o.amount, l.logistics_status, l.update_time FROM orders o JOIN logistics l ON o.order_id l.order_id ORDER BY o.order_id LIMIT 100;问题出在一个订单可能对应多条物流轨迹更新记录JOIN 之后行数从订单总数膨胀到轨迹总数LIMIT 100 截断后恰好把某个订单的路径切掉了一部分。从用户视角来看这就像把一本书的前 100 行拆开看了但其中一页是从中间撕开的。数据本身没错但展示语义完全坏了。排查过程是先看 JOIN 基数再对比两表的主键唯一性最终定位是典型的一对多 JOIN LIMIT 截断导致的分组信息残缺。解决思路有两条对订单 最新物流状态这种诉求应该让模型生成关联子查询或者窗口函数取每个订单最新的物流记录而不是裸 JOIN如果确实要看完整轨迹那就不要截断订单维度而是引导用户输入具体订单号再展开。我们最终在提示词层面加了当结果有明确的逻辑主键时优先保证逻辑主键的完整性而不是简单 LIMIT 前 100 行这条规则。4. 让用户知道这是前 100 条比把上限调到 1000 更值钱4.1 被截断提示信任感反而上升的关键设计做产品的时候我们有个很深的体会很多用户抱怨数据太少并不是真的嫌 100 行少而是他收到的结果没有明确告诉他这 100 行到底算怎么回事。他从你的系统里拿到一个数字就会天然默认这是完整结果这是人的思维惯性。所以解决 LIMIT 问题的第一要务不是把数字调大而是把这不完整这件事清晰地写进结果里。我们最后的产品方案是网关返回的结果里带上 truncation 信息。后端在改写 SQL 的时候分两种情况处理。明细查询用 LIMIT 101 试探如果实际返回 101 行就说明有更多数据前端展示的时候显示已返回前 100 行还有更多结果可使用筛选条件缩小范围聚合查询则额外请求一个分组总数或使用数据库估算。不要小看这一行提示上线之后用户提数据怎么不全的工单比例明显下降。原因很简单当一个人知道面前是冰山一角他会想办法去拿整座冰山当他不知道的时候他会以为眼前就是全部。4.2 加载更多与跳页的真相OFFSET 大页会被 LIMIT 架空很多人会想既然 100 行不够那我加一个加载更多分页不就行了这里有个数据库常识必须说清楚。如果分页实现是 OFFSET那第 N 页的查询实际上是LIMIT 100 OFFSET (N-1)*100数据库依然要扫描并丢弃前 (N-1)*100 行。它虽然只返回 100 行但工作量随页数线性增长用户点到最后几页查询会越来越慢最后还是会把资源打满。如果要给 NL2SQL 场景做分页唯一比较通用的是键集分页也就是基于一个唯一的排序键把下一页翻译成 id 上一页最后一条的 id。但它对即席生成的 SQL 要求太高需要网关准确地知道查询的主排序键这在自然语言查询里不太现实。所以我们最终的取舍是交互式查询保持 100 行上限不做大跳页真正要拿全量数据的用户走导出通道。4.3 大结果导出交互查询守 100批量任务走异步说到导出这是我们解决用户想要 3000 行、30000 行的最终方案。NL2SQL 平台增加了一个下载中心用户点导出全部结果系统把原始查询重新包装成一个异步任务由独立队列执行执行完成之后把结果写成 CSV 或 Parquet 文件供用户下载。导出任务的行数上限可以放宽但不能无限放宽我们默认给到 5 万行更大的需求走 DBA 审批。这里想强调的是交互查询和异步导出在架构上必须是两条链路交互查询追求的是低延迟和可交互性用 LIMIT 100 完全正确异步导出追求的是数据交付完整性要解决的是大结果集不撑爆内存、不拖垮库的问题。如果一开始就把交互查询的 LIMIT 调到 5000等于把两条链路的问题合并成一条两边都做不好。5. 100 这个数字该配给谁行数上限的动态配置5.1 不同场景的行数上限矩阵100 不是金科玉律。我们把行数上限做成一个按场景动态配置的策略之后才逐步摆脱了一行配置走天下的尴尬。目前我习惯用一个矩阵来决定默认值场景建议默认上限关键理由生产库在线交互查询100200快速验证保护主库大宽表/含 JSON 大字段50 以下行宽大传输和渲染成本高只读从库/分析数仓5001000资源隔离较好可容忍更宽结果Demo、教学环境200500展示数据丰富度优先高基数 GROUP BY 聚合查询50100组数过多时应提示换维度异步导出任务5 万超限审批数据交付完整优先这个矩阵不是拍脑袋定的它的含义是LIMIT 的上限应该跟着这个查询会消耗谁的生产资源、结果会被谁消费来变化。生产主库上的交互查询宁苛刻勿宽松分析数仓上的即席查询可以适度放宽。5.2 按用户、角色和库下发 limit 策略动态配置怎么落地我们是在 NL2SQL 网关里加了一个策略服务每个请求进来时根据用户所属角色、目标数据库类型和会话模式决定一组参数{ default_limit: 100, max_limit: 200, statement_timeout_ms: 5000, allow_export: true, export_max_rows: 50000 }比如访客角色 default_limit 只有 20只读分析组 default_limit 是 500某些大数据量宽表则单独把 default_limit 压到 30。这个参数会同时传给 SQL 改写模块和执行网关改配置不用发版热加载即可。更关键的是我们把用户在这个 limit 下有多少次点击加载更多、多少次发起导出、多少次提工单说数据不对全部埋点记录定期回看。如果你发现某类用户频繁导出那说明默认行数对这个场景太低了可以针对性上调如果发现截断提示出现率很低那说明 100 行对大多数查询够用不必为了少数场景调大全局值。5.3 配套监控截断记录是最好的模型反馈数据审批过 LIMIT 之后我们的审计日志多了一类非常有价值的数据截断事件。每逢返回行数恰好等于上限值说明发生了截断且用户没有继续操作这条请求就会被标记为潜在信息不完整。对应的日志字段大致包括user_id、question原始 SQL 与改写后的 SQL返回行数、是否截断执行耗时、目标库模型版本、提示词版本这些记录有两个用途。一是做实时告警比如截断率高企的模型版本说明这个版本特别爱出大结果集查询可以针对性迭代提示词二是做产品反馈截断后用户如果马上去加筛选条件或导出说明我们的提示结果不完整是有用的如果用户看到截断提示仍然什么都不做直接下载了前 100 行可能他是真的只要这些数据也可能他没看懂提示该优化 UI 了。顺便说一句大模型 API 的 429 限流问题也适用同样的思路先通过监控判断是并发过高还是配额不足再决定是降低重试频率、退避还是扩容而不是一上来把重试次数调到最大——重试本身也是另一种形式的limit设置不当照样会反过来打死你的上游。5.4 展示上限与真实 LIMIT 的边界感最后一个经验是关于两个 LIMIT的边界。执行层必须是真的 LIMIT 100让数据库只算前 100 行展示层的文案要强调预览。很多初做 NL2SQL 的团队会把两者混为一谈前端拿到一万行再分页或者只在前端截断数据但后端跑全量这些都是负优化。执行层的 LIMIT 是性能命脉展示层的提示是心智管理两者配合用户才会既看到即时反馈又不会误把样本当全集。我们内部还定了一条产品话术规范在 UI 上不直接暴露LIMIT 100这种 SQL 字眼而是统一叫预览模式。因为LIMIT对业务用户来说是数据库术语隐藏着一种系统故意瞒着我的暗示而预览是一个中性词它天然带有这不是全部可以获取更多的预期。同一个技术事实措辞一变用户对产品的信任感完全不一样。说回文章开头那位发 429 报错截图的同事我后来回了他一句API 限流只是大模型服务在保护自己而我们给 NL2SQL 加的 LIMIT 100是在替用户保护数据库也是在替用户保护他对数据的判断力。前阵子有个产品经理问我100 行是不是太小气了用户老说数据少要不要直接调成 5000 我说你先别急着调回去问一下那个用户当你看到 5000 行的时候你知不知道这 5000 行是不是全部如果你不知道那 5000 跟 100 没有本质区别。后来我们做完了截断提示和导出通道那个产品经理自己也说现在 100 比之前的 5000 还好用。 我们真正要做的从来不是把数字调大而是让用户对我拿到的是不是全部这件事永远心里有数。
返回列表