上一篇讲「只允许 SELECT」做了三遍。评论区有人说:你这纵深做得够深了,LIMIT 不就是补一行吗,有什么好写的?
LIMIT 本身确实是一行。但围绕这一行,产品判断、安全兜底、方言差异、运维可改、前端承受力,五件事交织在一起。 写错一个,要么用户抱怨「只给我看 100 条」,要么业务库和前端一起喘。
这篇就用 STAR 的骨架讲:默认 100 是怎么来的(情境),LIMIT 在系统里承担什么(任务),我们怎么实现、怎么改、怎么防炸(行动),以及边界在哪(结果)。
仓库在这: github.com/yanqiuping1...
一、默认 100 不是拍脑袋(S:情境)
问数页面是对话式的。用户问「上个月各学校的跳绳人数」,期望看到一张能扫一眼的表格,不是一次导出三万行。
100 行是「够看、不炸」的平衡点:
- 够看: 大部分 GROUP BY 查询,维度不超过几十个。100 行覆盖「按学校」「按月份」「按活动类型」等常见粒度;
- 不炸: 前端表格组件一次渲染几百行还行,上万行开始卡。SSE 推流时数据量也要控制;
- 不偏: 用户没写 LIMIT 时,不补等于放全量。全量查询打到业务库,DBA 会来找你。
关键产品判断: 100 这个数字存在系统参数表里,运维可以改。但默认值必须保守------新部署、没人配参数的时候,系统应该自我保护,而不是默默拖爆。
二、LIMIT 在系统里承担什么(T:任务)
LIMIT 不是一行 SQL 的事,它是数据库、应用层、前端渲染、网络推流四个环节的共同约束:
| 环节 | 怕什么 |
|---|---|
| 业务库 | 全量查询拖垮数据库 |
| 应用层 | 拉回海量行处理不过来 |
| 前端渲染 | 表格组件一次渲染上万行卡顿、白屏 |
| SSE 推流 | 行数多 = 事件体积大 = 推流时间长,用户等不及刷新页面 |
所以 LIMIT 的目标不是「限制用户」,是让四个环节都不出问题。想放大,四个环节都得确认能扛。
三、怎么实现:三层配合(A:行动)
第一层:模型写大了,压回去
模型不总是忘 LIMIT。有时候它会写 LIMIT 10000------因为用户说了「给我全部」。
闸门的逻辑:
| 模型写了什么 | 闸门做什么 | 结果 |
|---|---|---|
| 没写 LIMIT | 补默认值 | 最多 100 行 |
写了 LIMIT 50 |
不动 | 50 行 |
写了 LIMIT 10000 |
压回上限 | 最多 100 行 |
写了 LIMIT 0 或负数 |
补默认值 | 最多 100 行 |
产品判断:模型的 LIMIT 不等于用户的需求。 用户说「全部」,真正想看的可能是「不只 10 条」。给 100 条够不够?大部分时候够。真要导全量,走下载 / 报表,别走对话------对话是给人看的,不是给导数的。
第二层:探查工具更狠------LIMIT 10
Agent 在问数链路里有个工具叫"探查 SQL",用来验证 JOIN 是否正确、字段取值分布长什么样。探查的目的是「看一眼结构」,不是「拉数据」。
探查 SQL 的 LIMIT 封顶到 10,原因有三:
- Agent loop 可能跑 3~6 轮,每轮可能探查一次。6 轮 × 大量行 = 上下文爆炸。10 行看取值分布够了;
- 探查是中间步骤,用户不会看到。拉多了浪费 token 和网络;
- 防诱导:探查如果不限,Agent 可能被注入问句诱导反复拉大量数据。10 行 + 步数上限双重封顶。
第三层:执行器兜底------多取一行检测溢出
LIMIT 改写是 sqlglot 层面的。B4 讲过:方言坑可能让 LIMIT 没生效(比如 SQL Server 的 TOP 和 LIMIT 映射不一致、Oracle 的 FETCH FIRST 被方言标签搞丢)。
执行器不信任 LIMIT 改写的结果。 它用「多取一行」的方式检测溢出------如果数据库返回超过上限的行数,说明 LIMIT 没生效:
python
# backend/app/sql/executor.py(节选)
raw_rows = result.fetchmany(max_rows + 1)
if len(raw_rows) > max_rows:
raise SqlGuardError("TOO_MANY_ROWS", f"结果超过 {max_rows} 行上限")
为什么是 max_rows + 1?不是为了多给一行,是为了检测溢出 。拿到 101 行说明数据库返回了超过 100 行------LIMIT 没生效。这时候不是默默截断,而是报错。
为什么报错比截断好?因为截断比报错危险------用户以为看到了全部,其实不是。报错至少让人知道「还有」。
两层 LIMIT 的关系: sqlglot 改写是让数据库少干活(SQL 层面),fetchmany 兜底是让应用层不崩(应用层面)。两层抓的是同一个风险(拖爆),但在不同层面兜。
四、谁能改、怎么改、改完怎么不炸(A:行动)
sql_max_rows 有三个来源,优先级从高到低:
| 来源 | 谁改 | 什么时候生效 |
|---|---|---|
| 系统参数表 | 运维 / 管理员通过管理端 | 热生效 |
| 环境变量 | 部署时配 .env |
重启生效 |
| 代码默认值 | 开发者 | 兜底 |
运维改参数表,不用重启。而且参数本身被夹在安全范围内(比如 1~10000)------运维改成 0 或 99999,也会被夹回安全范围。
改完前端会不会炸? 会,如果改太大。前端表格组件 100 行没压力,1000 行开始卡顿,5000 行以上可能白屏。更隐蔽的是 SSE 推流:用户等 10 秒看到 100 行能接受,等 30 秒看到 5000 行大概率会刷新页面。
所以改大之前,先确认四个环节都扛得住。 默认值保守就是为了让没配参数的新部署不至于出事。
五、几笔取舍,我现在还认(R:结果)
默认 100,不是 1000。 保守的默认值保护不懂配参数的部署者。想要更多行,显式改参数,别让默认放行。
压回去,不是警告。 模型写 LIMIT 10000,不是提示用户「建议减少」,是直接压到 100。对话场景不需要万行结果------这不是限制用户,是保护用户(和业务库)。
报错,不截断。 截断意味着用户看到不完整的数据但以为是完整的。报错至少让人知道「还有」。
探查 10 行,不可商量。 Agent 的探查是中间步骤。10 行看取值分布够了。如果模型需要看更多,那是 Plan 层的问题------该拆查询,不该拉全量。
上限 10000,不是无限。 即使运维想放开,也夹在 10000。这是「对话问数」的场景约束。真要超过 10000 行的导数需求,应该走报表 / 下载 / ETL,不走对话。
带走三句
- LIMIT 默认 100 不是拍脑袋,是「够看、不炸」的平衡------数据库、应用层、前端、推流四个环节的共同约束;
- 模型写大了压回去,没写就补,执行器多取一行检测溢出------LIMIT 在两层做,抓的是同一个风险;
- 想改可以改(系统参数表,热生效),但改完要确认前端扛得住。
开源仓库在下面,本地可以跑通(Compose + Fixture,不必先配云钥匙)。说明看 docs/DEMO.md / AGENTS.md。
GitHub: github.com/yanqiuping1...