1 分钟

含 100k 行的仪表板列表加速:首先该做什么

学习如何通过分页、虚拟化、智能筛选和更高效的查询来构建可处理 100k 行的快速仪表板列表,让内部工具保持流畅。

含 100k 行的仪表板列表加速:首先该做什么

列表页面随着数据增长变慢的原因

列表界面通常在一个临界点之前感觉良好,之后就出现问题。用户会注意到一些小的停顿累积起来:滚动卡顿、每次更新后页面短暂卡住、筛选需要几秒钟才响应,每次点击都会看到加载指示器。有时浏览器标签页会看起来像是挂起了,因为 UI 线程被占用。

100k 行是一个常见的拐点,因为它会同时给系统的每一部分施压。对于数据库来说数据量仍然正常,但对浏览器和网络而言足够大,会把原本微小的低效放大。如果尝试一次展示所有数据,一个简单的页面就会变成繁重的处理链。

目标不是渲染所有行,而是帮助用户快速找到所需内容:正确的 50 行、下一页或基于筛选的狭窄切片。

把工作分成四部分会更清楚:

  • 网络:发送了多少字节,以及发送频率。
  • 数据库:每次请求要扫描、排序和聚合多少数据。
  • 浏览器渲染:创建、测量和绘制了多少 DOM 节点。
  • JavaScript 工作:重新渲染、重新计算列或重算结果的频率。

任何一部分变得昂贵,整页就会显得慢。一个简单的搜索框可能触发对 100k 行的排序,返回数千条记录,然后迫使浏览器全部渲染。这就是为什么输入会变得卡顿的原因。

当团队快速构建内部工具(包括使用像 Koder.ai 这样的低代码/聊天驱动平台)时,列表页面常常是现实数据增长将“在演示数据上能工作”与“每天感觉很流畅”之间差距暴露出来的第一个地方。

选择合适的性能目标

在优化之前,先确定该界面的“快”是什么意思。很多团队追求吞吐量(加载所有数据),而用户更关心低延迟(看到内容快速更新)。只要滚动、排序和筛选响应迅速,一个列表即使从未加载完整 100k 行也能给人“瞬时”的感觉。

一个实用的目标是首行时间,而不是完全加载时间。用户在看到前 20 到 50 行快速出现并且交互保持顺滑时,会信任页面。

要度量的内容(及原因)

选择一组小而关键的数字,在每次改动后都能追踪:

  • 打开页面或更改筛选后的首行时间
  • 筛选或排序显示更新所需时间
  • 响应大小(大致 JSON 负载)
  • 慢查询(尤其是 COUNT(*) 和 宽 SELECT
  • 浏览器主线程峰值(滚动卡顿、输入延迟)

这些指标能对应常见症状。如果滚动时浏览器 CPU 激增,说明前端在每行上做了太多工作。如果加载器等待但滚动随后顺畅,通常是后端或网络问题。如果请求很快但页面仍然冻结,几乎总是渲染或大量客户端处理的问题。

一个快速的前端 vs 后端 测试

做一个简单实验:保持 UI 不变,但临时让后端只返回 20 行(使用相同的筛选)。如果变快,瓶颈在加载大小或查询时间;如果仍然慢,检查渲染、格式化和每行组件的开销。

例如:一个内部的 Orders 页面在输入搜索时感觉很慢。如果 API 返回了 5,000 行并且浏览器在每次按键时都在这些行上做过滤,输入肯定会滞后。如果 API 因为在未建立索引的字段上做 COUNT 而耗时 2 秒,你会在任何行变化之前看到等待。不同的修复方法,但用户抱怨的是同样的结果。

前端基础:让渲染保持轻量

浏览器常常是首个瓶颈。即便 API 很快,页面也可能因为试图绘制过多内容而感觉迟缓。第一个规则很简单:不要在 DOM 中同时渲染数千行。

即便在启用完整虚拟化之前,也要让每行尽量轻量。包含嵌套容器、图标、提示、以及大量条件样式的行在每次滚动和更新时都会付出代价。优先使用纯文本、少量徽章,以及每行仅保留一两个交互元素。

稳定的行高比听起来更重要。当每行高度一致时,浏览器能更好地预测布局,滚动更平滑。可变高度行(折行的描述、可展开的备注、大头像)会触发额外测量与重排。如果需要额外细节,考虑侧边面板或单一可展开区域,而不是多行显示。

格式化也是一个安静的开销。日期、货币和大量字符串操作在多列多行重复时会累积成本。

经验法则

如果一个值当前不可见,就不要现在计算。缓存昂贵的格式化结果,在行变为可见或用户打开行时再计算它们。

一套常见且常见带来显著提升的做法:

  • 将初始渲染限制在小页(或虚拟窗口)内的行
  • 保持行高固定,避免多行单元格
  • 把重 UI(提示、菜单)延迟到 hover 或点击时加载
  • 对每行的日期和金额格式化做 memoize 或缓存
  • 当只有一个筛选变更时,不要重渲染所有行

示例:一个内部的发票表格需要格式化 12 列的货币和日期,在滚动时会卡顿。对每张发票缓存格式化值并延迟对屏幕外行的工作,往往能在不动数据库的情况下让体验接近“瞬时”。

虚拟化:如何让 100k 行滚动流畅

虚拟化意味着表格只绘制用户实际能看到的行(以及上下的小缓冲)。随着滚动,它复用相同的 DOM 元素并替换其中的数据。这样浏览器不会尝试一次性绘制成千上万的行组件。

当你有超长列表、宽表格或“重”行(头像、状态标签、操作菜单、提示)时,虚拟化非常有用。也适合用户经常滚动并期望连续平滑视图而不是逐页跳转的场景。

虚拟化的难点

它不是魔法,下面这些情况常常带来意外:

  • 可变行高(折行文本、可展开行)会破坏平滑滚动,除非你测量高度或保证行高一致。
  • 粘性表头和粘性列如果依赖复杂 CSS,可能与虚拟化容器冲突。
  • 键盘导航(上下、PageUp/PageDown、单元格焦点)需要额外处理,避免焦点跳到未挂载的行上。
  • 批量操作需要明确规则:是选择可见行、跨筛选选择,还是跨整表选择。

最简单的做法是乏味但稳健:固定行高、可预测的列、并且每行不要放太多交互控件。

虚拟化与分页的结合(并不让用户迷惑)

可以结合使用:用分页(或基于游标的“加载更多”)限制从服务器获取的数据量,用虚拟化在取到的片段内保持渲染轻量。

一种实用模式是获取常规页大小(通常 100 到 500 行),在该页内做虚拟化,并提供清晰的翻页控件。如果使用无限滚动,加入可见的“已加载 X / 共 Y”指示,让用户知道他们并未看到全部数据。

保持快速的分页选择

Tune safely with snapshots
Experiment with query shaping and roll back fast if a change slows the screen.

要让列表随数据增长仍可用,分页通常是最稳妥的默认方案。它可预测,适合审核、编辑、批准类的后台工作流,并支持“导出带有这些筛选的第 3 页”这类常见需求。很多团队在尝试复杂滚动后最终还是回到分页。

无限滚动对随意浏览可能更友好,但它有隐性成本。用户会失去定位感,后退按钮往往无法回到原来位置,长时间会话会随着更多行加载占用内存。一种中间做法是“加载更多”按钮,仍基于分页而用户感受更自然。

偏移分页 vs 键集分页

偏移分页是经典的 page=10&size=50 方式。简单但在大表上后页可能变慢,因为数据库需要跳过许多行才能到达后面的页面。并且当新行插入时,分页内的项会发生偏移,体验会显得奇怪。

键集分页(常称游标分页)则请求“最后看到的项之后的下一批 50 条”,通常基于 idcreated_at。它通常保持快速,因为不需要大量跳过或计数工作。

实用规则:

  • 对小表、数据稳定或需要跳到精确页码时使用偏移分页。
  • 对非常大且频繁插入的数据、或需要稳定的下一页/上一页导航时使用键集分页。

在不付出高成本的情况下显示总数

用户喜欢看到总数,但对匹配筛选做一次完全 count 在复杂筛选上可能非常昂贵。选项包括为常见筛选缓存计数、在页面加载后后台更新计数,或显示近似数(例如 “10,000+”)。

示例:一个内部 Orders 页面可用键集分页即时显示结果,然后在用户停止更改筛选一段时间后再填充精确总数。

如果你在 Koder.ai 上构建,把分页和计数行为作为屏幕规范的一部分早早确定,这样生成的后端查询和 UI 状态不会互相冲突。

让筛选与搜索感觉即时

大多数列表之所以感觉慢,是因为它们起始时开得太开:加载所有数据,再等用户缩小范围。把顺序反过来。默认使用能返回小而有用集合的合理默认值(例如:最近 7 天、我的项目、状态:Open),把“全部时间”设为显式选择。

文本搜索是另一个常见陷阱。如果在每次键入时都运行查询,你会产生请求积压并让 UI 闪烁。对搜索输入做去抖(debounce),并在新请求开始时取消旧请求。简单规则:用户仍在输入时,不要触发服务器请求。

筛选要既快又清晰。把筛选标签(chip)放在表格顶部附近,让用户能一眼看到哪些筛选生效并一键移除。标签文本应对用户友好,而不是原始字段名(例如显示 “Owner: Sam” 而非 owner_id=42)。当有人说“我的结果不见了”时,通常是某个不可见的筛选在起作用。

使大型列表在不复杂化 UI 的前提下保持响应的模式:

  • 默认使用收窄的筛选和较短的日期范围
  • 对文本搜索去抖并取消正在执行的请求
  • 展示筛选标签并提供“一键清除”操作
  • 提供常用的保存视图
  • 对昂贵筛选在至少存在一个收窄筛选时才开放

保存视图是低调的英雄。不必每次教用户构建完美的一次性筛选组合,提供匹配真实工作流的预设更有价值。运维团队可能在 “今日失败支付” 与 “高价值客户” 之间切换,这些视图一键即可理解且更容易在后端保持快速。

如果你在像 Koder.ai 这样的聊天驱动构建器里做内部工具,把筛选视为产品流的一部分,而不是事后添加的功能。先从最常见的问题开始,围绕这些设计默认视图和保存视图。

查询塑形(Query shaping):少传、少算

列表视图很少需要详情页同样的数据。如果 API 返回了关于每个对象的所有信息,你会付出双重代价:数据库做更多工作,浏览器接收并渲染比实际需要更多的内容。查询塑形的习惯是只请求列表当前需要的字段。

先只返回渲染每行所需的列。对大多数仪表板来说,这通常是 id、几项标签、状态、负责人和时间戳。大的文本字段、JSON 块和计算字段可以等到用户打开行时再请求。

首次渲染时避免昂贵的 join 操作。Join 在命中索引并返回小结果时没问题,但当你 join 多个表并基于连接字段排序或筛选时,就会变得昂贵。一种简单模式是:先从主表快速获取列表,然后按需加载关联详情(或仅为可见行批量加载)。

限制排序选项并确保按已建立索引的列排序。"按任意列排序" 听起来有用,但往往迫使在大数据集上做慢速排序。优先提供少量可预测的选项,如 created_atupdated_atstatus,并为这些列建立索引。

谨慎处理服务端聚合。对巨大的过滤集合做 COUNT(*),对宽列做 DISTINCT,或计算总页数都可能主导响应时间。

实用做法:

  • 将列表响应限定为 5 到 10 个字段
  • 仅在用户打开行时获取行详情
  • 允许 2 到 4 个排序选项,且都由索引支持
  • COUNTDISTINCT 视为可选,必要时缓存或近似处理

如果你在 Koder.ai 上构建内部工具,先在规划阶段把轻量的列表查询和详情查询分开定义,这样随着数据增长 UI 才能保持流畅。

针对大表的数据库策略

Keep the code portable
Export source code when you need full control over optimizations and custom behavior.

要让列表在 100k 行时仍保持快速,数据库每次请求必须做更少的工作。多数慢列表并不是“数据太多”,而是数据访问模式不对。

从匹配用户真实行为的索引开始。如果列表通常按 status 筛选并按 created_at 排序,那么你需要一个能同时支持这两者的索引,否则数据库可能扫描比预期更多的行再去排序,成本会迅速上升。

常见且有效的修复:

  • 添加反映常见筛选 + 排序 的复合索引(例如 tenant_id, status, created_at)。
  • 在深分页时优先使用键集(游标)而非 OFFSET,因为 OFFSET 会让数据库遍历许多被跳过的行。
  • 把总数视为可选;精确总数在大过滤集合上会很慢。缓存计数、预计算或显示“10,000+”等近似值。
  • 保持行尽量精简。列表不要 select 大文本字段、JSON 或嵌套对象。
  • 将查询塑形为只返回 UI 需要的列:表格所用字段加上下一页游标。

简单示例:内部 Orders 表需要显示客户名、状态、金额和日期。不要在列表视图中 join 所有关联表并拉取完整订单备注。只返回表格使用的列,用户点击订单时再单独请求剩余详情。

如果你用像 Koder.ai 这样的平台构建,即使 UI 是通过聊天生成的,也要保持这种思路:确保生成的 API 接口支持游标分页和字段选择,这样随着表增长数据库的工作量才可预期。

加速现有列表页面的分步计划

如果某个列表页面今天感觉慢,不要一上来就重写所有东西。先锁定正常使用路径,然后优化那条路径。

一个实用的五步计划

  1. 定义默认视图。 选择默认筛选、排序和可见列。列表在试图默认展示一切时最容易变慢。

  2. 选择与使用模式匹配的分页风格。 如果用户主要查看前几页,经典分页就足够;如果用户会跳到很深的页(第 200 页及以后)或需要在任意深度都保持稳定性能,使用键集分页(基于稳定排序如 created_at 加 id)。

  3. 为表体添加虚拟化。 即便后端很快,浏览器在渲染过多行时也会卡顿。

  4. 让搜索和筛选感觉即时。 对输入去抖,避免每次按键都发请求。将筛选状态保存在 URL 或单一共享状态存储中,以保证刷新、后退和分享视图时行为可靠。缓存最近一次成功结果,避免表格闪成空白。

  5. 度量,然后调整查询和索引。 记录服务器时间、数据库时间、负载大小和渲染时间。然后精简查询:只选取显示的列,尽早应用筛选,并为默认筛选 + 排序添加索引。

示例:一个 100k 条工单的支持仪表板。默认筛选为 Open、分配给我的团队、按最新排序,显示六列且只拉取 ticket id、subject、assignee、status 和时间戳。结合键集分页和虚拟化,数据库和 UI 都能保持可预测性。

如果你在 Koder.ai 中构建内部工具,这个计划很适合迭代并检验的工作流:调整视图、测试滚动和搜索,然后调优查询直到页面保持流畅。

常见错误让列表性能变糟

Ship pagination first
Create a dashboard list with pagination, filters, and sane defaults in minutes.

把 100k 行当成普通页面数据来处理是让列表坏掉的最快方法。大多数慢仪表板都有一些可预测的陷阱。

一个大坑是把所有数据渲染出来然后用 CSS 隐藏。即便看起来只有 50 行可见,浏览器仍需为创建 100k 个 DOM 节点、测量它们并在滚动时重绘付出代价。如果需要长列表,就仅渲染用户可见的部分(虚拟化),并保持行组件简单。

搜索也可能悄悄毁坏性能,尤其是每次按键触发全表扫描时。通常是因为筛选没有索引、搜索跨太多列,或者在巨大文本字段上做包含查询而没有策略。一个好规则:用户首选的筛选应该在数据库上是廉价的,而不仅仅是在 UI 上方便。

另一个常见问题是为列表拉取完整记录,而列表其实只需要摘要。列表行通常需要 5 到 12 个字段,而不是整个对象、冗长描述或关联数据。拉取额外数据会增加数据库工作、网络时间和前端解析成本。

导出和总数计算如果在主线程或在等待重请求时执行,会冻结 UI。保持 UI 可交互:后台启动导出、显示进度,并避免在每次筛选变化时重新计算总数。

最后,过多的排序选项会适得其反。如果用户能按任意列排序,你会在大结果集上做内存排序或迫使数据库走慢查询计划。将排序限制在少数已建立索引的列,并让默认排序与真实索引匹配。

快速自检:

  • 如果可以无限滚动而不加载,说明你可能渲染了过多内容。
  • 如果在搜索时输入变慢,查询可能在做全表扫描。
  • 如果列表 API 返回了巨大的 JSON,说明你在过度获取数据。
  • 如果导出挂起页面,说明工作在错误的地方发生。
  • 如果每次排序都慢,说明需要整理索引和排序选项。

快速清单与下一步

把列表性能当作产品特性来处理,而不是一次性的修补。列表页面只有在真实用户在真实数据上滚动、筛选和排序时感觉快,才算快。

用这个清单确认你修复了正确的问题:

  • 首屏渲染快:页面加载小负载并迅速显示首批行。
  • 滚动保持流畅:启用了虚拟化,浏览器 CPU 不会突然飙升,行高可预期。
  • 筛选响应快:输入或选择筛选后结果快速更新,分页或刷新时筛选不会丢失。
  • 排序合理:只允许由索引或预计算字段支持的排序,且在各页之间排序行为一致。
  • 请求已塑形:API 仅返回你展示的列、稳定 ID 和游标,而非一次性返回完整对象。

一个简单的现实检验:打开列表,滚动 10 秒,然后应用一个常见筛选(例如 Status: Open)。如果 UI 冻结,问题通常是渲染(渲染了过多 DOM 行)或在每次更新时发生的大量客户端转换(排序、分组、格式化)。

接下来的步骤,按顺序进行,避免在修复间来回跳:

  1. 测量一个用户流的端到端时间(加载、滚动、筛选、排序),并写下目标时间。
  2. 开启虚拟化并限制单元格中昂贵的格式化(日期、货币、头像)。
  3. 把筛选和排序搬到服务器,并限制为能快速执行的选项。
  4. 收紧查询和返回负载,然后重新测量。

如果你用 Koder.ai(koder.ai)构建,从 Planning Mode 开始:先定义确切的列表列、筛选字段和 API 返回形态。然后通过快照迭代,若某个实验让页面变慢可回滚。

常见问题

为什么我的列表页面刚开始看起来正常,但到达 100k 行时就变慢?

把目标从“加载所有数据”改为“尽快显示第一批有用的行”。优化首行时间和在过滤、排序、滚动时的交互流畅度,即便完整数据集从不全部加载也没关系。

慢列表页面最有用的监测指标有哪些?

衡量加载或过滤变化后的首行时间、过滤/排序更新所需时间、响应负载大小、慢查询(尤其是宽 SELECTCOUNT(*)),以及浏览器主线程的峰值。这些数据直接对应用户感知的“卡顿”。

如何快速判断瓶颈是在前端还是后端?

临时让 API 在相同过滤和排序下只返回 20 行。如果变快,瓶颈主要在查询成本或载荷大小;如果仍然慢,问题通常在渲染、格式化或每行的客户端计算上。

有哪些最容易实现的前端改进可以加速大表?

不要在 DOM 中渲染成千上万行,保持行组件简单,并优先固定行高。还要避免为屏幕外的行做昂贵的格式化工作;当行可见或打开时再计算并缓存格式化结果。

什么时候应该使用虚拟化,使用后会有哪些问题?

虚拟化只挂载可见的行(加上上下小缓存),复用 DOM 元素随滚动替换数据。它适合用户频繁滚动或行比较“重”的场景,但最好保证行高一致、表格布局可预测。

对大数据集来说,分页比无限滚动更好吗?

分页通常是大多数管理型和内部工作流的更安全默认,因为它有助于用户定位并限制服务器工作量。无限滚动在休闲浏览场景可以接受,但会带来导航、后退行为和内存增长的问题,除非加入明确的状态处理和限制。

我应该使用偏移分页还是键集(游标)分页?

偏移分页(page=10&size=50)更简单但深页会变慢,因为数据库需要跳过很多行。键集(游标)分页通常保持稳定且更快,适合大量数据和频繁插入,但不便于直接跳转到特定页码。

如何在不压垮服务器的情况下,让搜索和筛选感觉即时?

不要在每次键入时发送请求。对输入做去抖(debounce),在新请求开始时取消未完成请求,并默认使用收敛性更强的筛选(如最近日期或“我的项”),使首次查询返回小而有用的结果。

我的列表 API 应该返回什么以保持高性能?

API 只返回列表实际渲染所需的字段,通常是 id、标签、状态、负责人和时间戳等小集合。把大文本、JSON 块和大多数关联数据留到详情接口再去取,以保证首屏轻量且可预测。

针对 100k 行的列表,哪些数据库改动最有帮助?

让默认的过滤和排序匹配真实使用场景,然后为此添加索引(通常是组合索引,把租户/筛选字段和排序列放在一起)。把精确总数当成可选项:缓存、预计算或展示近似值,避免阻塞主列表响应。

Related posts