大量資料的效能做法
目標:資料表從幾千筆長到上百萬筆之後,列表頁、選單與檢查仍然很快。畫面變慢,常見的原因是:它處理的資料量會跟著整張資料表增加,但其實只需要跟著「這一頁要顯示的量」增加。下面的做法就是讓工作量只跟畫面實際顯示的內容成正比。多數章節的最後都附有詳細說明所在頁面的連結。
如果你負責管理資料表、不是開發畫面,只要讀「一覽」與最後一節;其餘章節給開發人員看。
範例用的是聊天室範圍的資料表。部門與公司範圍的參數與 body 完全相同,只有路徑不同(見範圍與掛載路徑)。範例中的 UUID 與資料都是虛構的,請換成你自己的。
一覽
| 你的畫面… | 建議做法 | 不要這樣做 |
|---|---|---|
| 列出資料列 | 用 cursor 分頁(page_mode=cursor),每頁 25 到 100 筆 | 用 skip 或 offset 跳頁、依賴回應的 total |
| 顯示「共 1,280 筆」 | 畫面真的要顯示這個數字時,呼叫一次 records/count | 讀 total,或每翻一頁就算一次 |
| 從 agent 工具、MCP 或 workspace SDK 讀取資料列 | cursor 分頁(has_more、next_cursor),有人問「有幾筆」時才計數 | 每一頁都去讀總數 |
| 做指令選單 | GET …/command-summaries | GET …/commands(它會把這一頁每支指令的完整定義一起回傳) |
| 打開單一支指令 | GET …/commands?name=<完整名稱> | 先列出指令,再從清單裡找 |
| 在指令裡取出已知的幾筆資料 | 用 id 陣列 input 加 $in | 對整張表用其他欄位篩選 |
| 檢查庫存或餘額 | 讀存在欄位裡、由指令隨每次異動更新的餘額 | 檢查時才把整本流水(每一筆異動紀錄)加總 |
| 打開儀表板 | 使用者要求時才取彙總,或先快取 | 頁面一載入就跑彙總 |
1. 用 cursor 分頁,不要用 offset
offset 分頁每次請求都要讓伺服器數完所有符合的資料列,再跳過你這一頁之前的全部資料列。cursor(可以想成一枚書籤,記著上一頁讀到哪裡)分頁只讀你要的那一頁,外加多讀一筆來判斷後面還有沒有。在預設排序,或依 created_at 排序時,一頁的成本取決於頁面大小,而不是整張表有多少筆(後端在 30 萬筆上量到每頁約 40 毫秒)。其他從上一頁最後一筆接著讀的排序,不必越跳越多資料列,但後端沒有量測過:欄位沒有索引時,請預期它們的成本會隨符合的資料列數增加。用資料欄位篩選時,仍得去找出符合的資料列(第 7 節)。後端的目標是每頁最多 100 筆,它的測試用的是 50 筆。
第一頁加上 page_mode=cursor:
curl "$BASE_URL/private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/records?page_mode=cursor&limit=50" \
-H "Authorization: Bearer $TOKEN"回應會帶 has_more 與 next_cursor,但沒有總數:
{
"records": [ { "id": "33333333-3333-4333-8333-333333333333", "data": { "品項": "筆記本", "數量": 2 } } ],
"total": null,
"has_more": true,
"next_cursor": "eyJ2IjoxLCJiaW5kIjoiN2YzZTkyYTEiLCJvZmZzZXQiOjUwfQ.qT2uV0m9W4vJ0lM8c1nJrQ",
"table": { "id": "22222222-2222-4222-8222-222222222222", "record_count": null },
"resolved_principals": null
}(這裡省略了部分資料列與資料表內容。)要讀下一頁,就把 next_cursor 當成 cursor 送回去,篩選與排序維持不變:
curl "$BASE_URL/private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/records?cursor=$NEXT_CURSOR&limit=50" \
-H "Authorization: Bearer $TOKEN"POST …/records/search 把同樣兩個欄位放在 JSON body:
curl -X POST \
"$BASE_URL/private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/records/search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "page_mode": "cursor", "limit": 50, "filters": { "col_b2222222_2222_4222_8222_222222222222": 2 } }'下一頁就用同一份 body,再加上 "cursor": "<next_cursor>"。已儲存檢視的讀取(GET 與 POST …/views/{view_id}/records,那個 POST 別名沒有 body),以及整合檢視的 GET …/tables/{table_id}/view,這兩個欄位則放在 query string。
cursor 的行為:
- 原封不動送回,而且送回發出它的同一條路由。 它是不透明且有簽章的字串。不要解析、自己組合或修改它,也不要在收到它的畫面之外保存。
- 查詢條件要一致。 資料表、篩選、
q、排序與檢視,都要和取得這個 cursor 的請求相同。limit每頁可以不同;用 cursor 分頁時,skip與offset會被忽略。 - 以
has_more為準。 最後一頁與空的一頁都會回has_more: false與next_cursor: null。不要用「這一頁有沒有滿」來猜。 - 只能往後讀。 沒有「直接跳到第 7 頁」。要回上一頁,請自己保留用過的 cursor(見下面)。
- 權限或查詢一變就失效。 cursor 綁定查詢與你當下的權限。篩選、排序、檢視、資料表欄位結構或你的權限只要有變動,下一個請求就會回
400,請從第一頁重新開始。 - 排序較慢時,資料列可能移位。 依文字、日期、日期時間、布林、單選、多選或計算欄位排序時,在你目前位置之前新增或刪除一列,可能讓下一頁重複或漏掉一列;要刷新請從第 1 頁重來。
| 狀態 | detail.code | 意思 | 該怎麼做 |
|---|---|---|---|
400 | ct.invalid_cursor | cursor 被改過,或已和查詢、你的權限對不上 | 不帶 cursor,從第一頁重新開始 |
400 | ct.query_timeout | 這次讀取超過時間上限(預設 15 秒,limit_ms 會回報實際值) | 縮小篩選範圍或調低 limit,不要一直原樣重試 |
503 | ct.cursor_unavailable | 伺服器目前無法為 cursor 簽章 | 改回 offset 分頁,並通知維運人員 |
超過 8,192 個字元的 cursor 不會走到這一步:請求驗證會先以 422 拒絕。伺服器自己發出的 cursor 一定比這短。
排序方式會影響成本。依 id、created_at、updated_at、手動排列順序或數字欄位排序時,伺服器直接從上一頁的最後一筆接著讀,翻得很深的頁面,成本和第一頁差不多。依其他儲存欄位(文字、日期、日期時間、布林、單選、多選)排序也可以,但伺服器每次都得先跳過前面所有的資料列,越後面的頁越慢。依計算欄位(rollup、lookup、formula)排序時,只要資料表的現行資料列超過 50,000 筆,不論用不用 cursor,都會被 400 拒絕。很長的清單請盡量用預設排序。
把列表頁從 offset 改成 cursor
- 改請求。 加上
page_mode=cursor(搜尋放在 body 裡),保留limit,拿掉skip與offset。 - 用一個變數
cursor記住讀到哪裡。 第一頁沒有(仍要送page_mode=cursor,只是不帶cursor);每收到一頁就記下next_cursor,用它請求下一頁;has_more變成 false 就停。要讓使用者往回翻,請保留用過的 cursor 清單:第 1 頁沒有 cursor,要顯示上一頁,就用當初載入那一頁所用的 cursor 再請求一次。使用者切換每頁筆數時,請清掉這份清單,因為清單裡的 cursor 標記的是舊的分頁邊界。這份清單放在畫面自己的狀態裡(若要讓瀏覽器的「上一頁」鍵也能還原,就放在history.state),不要放進要分享的網址:cursor 只對同一位使用者、同一組查詢、同一份權限有效。 - 不要再讀
total與table.record_count。 在 cursor 模式下兩者都是null。頁碼改成「載入更多」或「上一頁/下一頁」。畫面要顯示數字的話,呼叫records/count(第 2 節)。 - 有任何變動就重設。 篩選、搜尋文字、排序或檢視一改,就丟掉 cursor,從第一頁重來。收到
400 ct.invalid_cursor也一樣處理。切換每頁筆數時可以沿用目前的 cursor,因為limit每頁可以不同,但要清掉先前 cursor 的清單。 - 可以一個列表一個列表分批改。 沒有帶
page_mode的請求行為和以前一樣,仍會回傳精確的total。total已列為棄用,等前端都遷移完成後(目前沒有訂出日期),會連同offset與skip一起移除;新的功能請不要再依賴它。
如果是背景工作之類、真的得讀完所有頁面的情況(一般畫面每次操作只取一頁),可以用這樣的迴圈:
type Page = { records: unknown[]; has_more: boolean; next_cursor: string | null }
async function readAllPages(listUrl: string, token: string): Promise<unknown[]> {
const rows: unknown[] = []
let cursor: string | null = null
do {
const url = new URL(listUrl)
url.searchParams.set('limit', '100')
if (cursor) url.searchParams.set('cursor', cursor)
else url.searchParams.set('page_mode', 'cursor')
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!response.ok) throw new Error(`read failed: HTTP ${response.status}`)
const page: Page = await response.json()
rows.push(...page.records)
cursor = page.has_more ? page.next_cursor : null
} while (cursor)
return rows
}完整契約見查詢資料列:篩選、排序與檢視。
2. 要顯示數字時才算筆數
cursor 分頁不會計算筆數。畫面真的需要顯示符合幾筆時,把送給搜尋的同一份 body 送到 POST …/records/count:
curl -X POST \
"$BASE_URL/private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/tables/22222222-2222-4222-8222-222222222222/records/count" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "filters": { "col_b2222222_2222_4222_8222_222222222222": 2 } }'{ "count": 12 }- 每個畫面、每組篩選算一次,不是每一頁都算。 畫面打開時,以及篩選或搜尋文字改變時(等使用者停止輸入之後)呼叫。先顯示清單,數字回來再補上。使用者翻頁時,這個數字不會變。
- 它是精確值,而且有時間上限。 它得逐筆數過所有符合的資料列,所以資料表非常大時仍可能要好幾秒,而且和分頁讀取受同一個時間上限約束(預設 15 秒,超過回
400 ct.query_timeout)。如果畫面顯示「50 筆以上」就夠用,has_more為 true 時直接顯示這個字樣,不必呼叫。 - 不要拿它判斷有沒有下一頁。
has_more本來就會告訴你,不需要額外成本。 - 它依呼叫者的權限計算。 套用和搜尋相同的租戶、列 ACL、欄 ACL 與 SCP 規則,所以同一張表,不同的人看到不同的筆數是正常的。
- 分頁與排序欄位會被忽略,所以呼叫端可以直接沿用搜尋的 body。搜尋沒有檢視 ID 這個欄位;要計算某個已儲存檢視的筆數,請送出該檢視的篩選條件。
- 筆數的開放範圍跟著搜尋走,所以要用使用者的 JWT。 整合 API key(
third_party、specific_chatroom或acquisition)呼叫這條路由會被拒絕,回405,和搜尋、列表一樣。
這條路由的欄位說明在資料列參考手冊。
3. 在指令裡用 id 取資料
只動到幾筆已知資料的指令,就應該只要那幾筆。指令的 input 可以是一串單純的值,select 步驟再用 $in 拿欄位去比對它。
{
"version": 2,
"inputs": [
{ "name": "item_ids", "type": "array", "required": true, "min_items": 1, "max_items": 50, "item_schema": "string" }
],
"steps": [
{
"kind": "select",
"name": "stock",
"from": { "table": "Stock", "as": "s" },
"select": {
"stock_id": "$row.s.id",
"sku": "$row.s.SKU",
"qty": "$row.s.Qty"
},
"where": { "$in": ["$row.s.id", "$input.item_ids"] },
"max_rows": 50
}
]
}(這是縮短過的定義;真正的指令還會宣告 outputs,通常也會寫入資料。)item_schema: "string" 表示這是一串字串。它也接受 integer、float、decimal、boolean、date 與 datetime,各自搭配型別相容的欄位使用;項目不能是 null,一個陣列 input 最多 200 個項目。$in 可以放在 where,也可以放在來源的 filter。
引擎怎麼處理,對你有什麼意義:
- 只讀步驟用到的欄位。
select、where、join、order_by與group_by裡提到的欄位才會被讀取;步驟沒用到的欄位,不會讓欄位很多的資料表變貴。 - 用
id篩選,成本和資料表大小無關。 一個 id,或透過$in給一串 id,都是用主鍵直接查:資料表變大,成本也幾乎不變(後端在 8 萬筆的資料表上量到:1 個 id 的中位數約 72 毫秒,50 個 id 約 82 毫秒;50 個 id 的第 95 百分位約 128 毫秒)。這適用於$in就是整個where(或來源filter),或是最上層$and的其中一項;放在$or、$not底下、用$not_in,或放在投影裡,就走一般的路徑。 - 用其他欄位篩選,成本會隨資料表變大。 引擎得逐筆查看資料列才找得到符合的。如果指令一定要靠 id 以外的東西找資料,請讓資料表維持在較小的規模。
細節見定義版本 2。
4. 把餘額存在欄位裡
「庫存夠不夠?」應該只讀一個數字。如果這個數字是整本流水加總出來的(例如 rollup,或加總每一筆已過帳異動的 select),每多一筆異動,檢查就更慢一點。請把餘額存在一個欄位,並讓過帳異動的指令在同一個 transaction 裡更新它,兩者要嘛一起成功,要嘛都不成立。再加一條 invariant 規則,讓餘額不會變成負數。
WMS:倉儲、流水與即時計算庫存就是這樣做的:庫存表上的 On Hand 是儲存的值,move_stock 指令在同一個 transaction 裡扣帳、入帳並寫入流水;流水上的 rollup 是用來回放與稽核,不是用來回答檢查。
5. 列表用摘要,單一支用名稱查
GET …/commands 一次回傳一頁指令(limit 預設 50、最多 200),每支都帶完整定義,量可能很大:在後端的測試中,對一個有 114 支大型指令的範圍送出 limit=200 的請求,回應約 17 MB。GET …/command-summaries 接受相同的 skip、limit 與 name,以相同的順序回傳相同的指令與相同的 total,但不含定義;同樣的請求約 80 KB。
curl "$BASE_URL/private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/command-summaries?limit=50" \
-H "Authorization: Bearer $TOKEN"每一列有 id、範圍 ID、name、description、agent_enabled、visibility、tag_id、execute_policy、已儲存定義的 version 與 mode、dependency_status、兩個遮蔽旗標、is_deleted、created_by 與時間戳記。每個選單與每份指令清單都請用它。需要單一支的定義時,用完整名稱查,或用 id 查:
curl "$BASE_URL/private/module/custom_tables/chatroom/11111111-1111-4111-8111-111111111111/commands?name=Create%20order" \
-H "Authorization: Bearer $TOKEN"兩條路由判定哪些指令看得到的方式相同,也都只接受 JWT。見自訂資料表 Commands。
6. 不要在頁面載入時就做彙總
彙總(POST …/records/aggregate)會對每一筆符合的資料列做運算,成本隨資料表變大。儀表板一打開就同時送出好幾個彙總,等於每次造訪都要為它們全部付出代價。請在使用者要求時才執行,把幾分鐘前的結果快取起來,或把這個數字存成由指令更新的儲存值(第 4 節)。如果只需要知道符合幾筆,用 records/count。
7. 型別索引由維運人員套用
用你自己定義的欄位篩選或排序,是從資料表的資料列去找答案,成本隨資料表變大;預設排序與依 created_at 排序是例外。為某個儲存欄位建立型別 projection,可以讓針對那個欄位的 stored_filters 條件與彙總便宜一些,但它是由負責這個環境的維運人員套用,不能透過這個 API 建立,而且數量有限。沒有它查詢也不會失敗,只是走比較慢的路徑。所以請圍繞預設排序來設計列表畫面;資料表很大、又需要在某一欄快速篩選時,請告訴維運人員是哪張表的哪一欄,由他們決定要不要建索引。見大量資料的型別化查詢值。
8. 大型回應在傳輸時會壓縮
只要呼叫端送出 Accept-Encoding: gzip,伺服器就會壓縮大型 JSON 回應:在後端的測試中,約 2.4 MB 的一頁資料列,傳輸量只有約 0.5 MB。瀏覽器會自動送出這個 header 並自動解壓縮,所以瀏覽器裡的 fetch 什麼都不用做。伺服器端的 HTTP 用戶端可能需要手動開啟(curl --compressed,或你使用的 HTTP 函式庫的對應選項)。解壓縮後的內容是同一份 JSON。
匯出檔、下載與串流不會被壓縮,大約 1 KB 以下的回應也維持原樣。壓縮需要伺服器多花一些處理時間,所以在較慢的連線上幫助最大;它不能取代分頁。
9. 看批次新增把時間花在哪裡
bulk-insert 的 ticket 會回報它何時送出、何時被領取、worker 跑了多久,以及每一個 500 筆 chunk 的耗時、鎖等待與 SQL。排隊時間是 claimed_at 減去 submitted_at。在動手改任何東西之前,先用它分辨是排隊太久,還是資料列本身慢。欄位說明見批次操作:mixed、非同步 tasks 與原子發布。
10. Agent、MCP 與 workspace SDK 的讀法也一樣
助理的工具、MCP 伺服器與 workspace SDK 遵循和畫面相同的兩個習慣:用 cursor 分頁,只在有人問「有幾筆」時才計數。各個介面的寫法不同:
| 介面 | 讀一頁 | 計數 |
|---|---|---|
Agent 工具(custom_tables_query_records、custom_tables_find_records) | 把上一次回覆的 next_cursor 當成 cursor 送回;has_more 為 false 就停 | custom_tables_query_records 搭配 operation: "count" |
MCP records_query | 第一頁用 page_mode: "cursor",之後送 cursor | records_count 工具 |
Workspace ct.query(以及 teamsync data ct query 指令) | page_mode: "cursor",之後送 cursor | operation: "count" |
| Python SDK | tables.query(table_id, cursor=…),一開始就是 cursor 模式 | tables.count(table_id) |
- cursor 分頁不做筆數統計。 agent 工具不回
total與total_pages;MCP 與 workspace 在 cursor 模式下回total: null。MCPrecords_query與 workspacect.query在預設的offset模式仍會計數,所以請明確要求 cursor 模式。 - 篩選與排序要一致,cursor 要原封不動送回。 對不上的 cursor 會被拒絕,回
ct.invalid_cursor(workspace 是invalid_cursor);請從第一頁重新開始。用到滾動時間窗(例如within_last)的讀取可能因此中斷,因為時間窗會隨時鐘移動:請改用絕對日期。 - 「有幾筆」才計數,「有沒有下一頁」不要計數。 後者由
has_more免費回答。計數要逐筆數過所有符合的資料列,並且和一頁資料共用同一個讀取時間上限。 - 助理自己也這樣做。 它的分析 agent 被要求沿著
next_cursor讀到has_more為 false,並且只在問題問「有幾筆」時才計數。 - MCP 是經過挑選的介面。 它有
records_count,而records_query與records_count都沒有computed_filters參數。
各個介面的參數見 Agent 工具箱頁面,MCP 的部分見這裡。
如果你負責管理公司的資料表,而不是開發畫面
你不會自己寫這些請求,但畫面變慢時,可以向負責開發或維運的同事問對問題:
- 很長的清單開啟或捲動很慢:問問清單是不是每次只載入一小批(25 到 100 筆),需要時才取下一批,而不是每次都讀完整張表。(開發人員稱這種做法為「cursor 分頁」。)
- 「資料筆數」出現得很慢:問問這個數字是不是每次搜尋只算一次,而不是每翻一頁就重算。
- 選指令很慢:問問選單是不是只載入名稱與說明(指令摘要),而不是每支指令的完整定義。
- 檢查庫存或餘額很慢:問問餘額是不是存成一個數字,由每次異動的指令一併更新,而不是每次都把過去所有的異動加總。
- 某張表只要依某個欄位篩選就一直很慢:請告訴維運人員(負責架設與維護這套系統的人)是哪張表的哪一欄,由他們決定要不要建立索引(為那個欄位建立的快速查找捷徑)。