最近在技术群里看到有人贴了一行报错截图: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 这种宽表,一行十来个字段,有些字段还是几百字节的 JSON,1000 行就是好几 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, read=dialect) if ast.args.get("limit"): return sql ast.set("limit", exp.Limit(expression=exp.Literal.number(default_limit))) return ast.sql(dialect=dialect)这段代码只是骨架,真正上线要考虑几个细节。第一,只有最外层的 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 不是金科玉律。我们把行数上限做成一个按场景动态配置的策略之后,才逐步摆脱了"一行配置走天下"的尴尬。目前我习惯用一个矩阵来决定默认值:
| 场景 | 建议默认上限 | 关键理由 |
|---|---|---|
| 生产库在线交互查询 | 100~200 | 快速验证,保护主库 |
| 大宽表/含 JSON 大字段 | 50 以下 | 行宽大,传输和渲染成本高 |
| 只读从库/分析数仓 | 500~1000 | 资源隔离较好,可容忍更宽结果 |
| Demo、教学环境 | 200~500 | 展示数据丰富度优先 |
| 高基数 GROUP BY 聚合查询 | 50~100 | 组数过多时应提示换维度 |
| 异步导出任务 | 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 还好用。" 我们真正要做的,从来不是把数字调大,而是让用户对"我拿到的是不是全部"这件事,永远心里有数。