Skip to Content
操作指南大量資料的效能做法

大量資料的效能做法

目標:資料表從幾千筆長到上百萬筆之後,列表頁、選單與檢查仍然很快。畫面變慢,常見的原因是:它處理的資料量會跟著整張資料表增加,但其實只需要跟著「這一頁要顯示的量」增加。下面的做法就是讓工作量只跟畫面實際顯示的內容成正比。多數章節的最後都附有詳細說明所在頁面的連結。

如果你負責管理資料表、不是開發畫面,只要讀「一覽」與最後一節;其餘章節給開發人員看。

範例用的是聊天室範圍的資料表。部門與公司範圍的參數與 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-summariesGET …/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意思該怎麼做
400ct.invalid_cursorcursor 被改過,或已和查詢、你的權限對不上不帶 cursor,從第一頁重新開始
400ct.query_timeout這次讀取超過時間上限(預設 15 秒,limit_ms 會回報實際值)縮小篩選範圍或調低 limit,不要一直原樣重試
503ct.cursor_unavailable伺服器目前無法為 cursor 簽章改回 offset 分頁,並通知維運人員

超過 8,192 個字元的 cursor 不會走到這一步:請求驗證會先以 422 拒絕。伺服器自己發出的 cursor 一定比這短。

排序方式會影響成本。依 id、created_at、updated_at、手動排列順序或數字欄位排序時,伺服器直接從上一頁的最後一筆接著讀,翻得很深的頁面,成本和第一頁差不多。依其他儲存欄位(文字、日期、日期時間、布林、單選、多選)排序也可以,但伺服器每次都得先跳過前面所有的資料列,越後面的頁越慢。依計算欄位(rollup、lookup、formula)排序時,只要資料表的現行資料列超過 50,000 筆,不論用不用 cursor,都會被 400 拒絕。很長的清單請盡量用預設排序。

把列表頁從 offset 改成 cursor

  1. 改請求。 加上 page_mode=cursor(搜尋放在 body 裡),保留 limit,拿掉 skip 與 offset。
  2. 用一個變數 cursor 記住讀到哪裡。 第一頁沒有(仍要送 page_mode=cursor,只是不帶 cursor);每收到一頁就記下 next_cursor,用它請求下一頁;has_more 變成 false 就停。要讓使用者往回翻,請保留用過的 cursor 清單:第 1 頁沒有 cursor,要顯示上一頁,就用當初載入那一頁所用的 cursor 再請求一次。使用者切換每頁筆數時,請清掉這份清單,因為清單裡的 cursor 標記的是舊的分頁邊界。這份清單放在畫面自己的狀態裡(若要讓瀏覽器的「上一頁」鍵也能還原,就放在 history.state),不要放進要分享的網址:cursor 只對同一位使用者、同一組查詢、同一份權限有效。
  3. 不要再讀 total 與 table.record_count。 在 cursor 模式下兩者都是 null。頁碼改成「載入更多」或「上一頁/下一頁」。畫面要顯示數字的話,呼叫 records/count(第 2 節)。
  4. 有任何變動就重設。 篩選、搜尋文字、排序或檢視一改,就丟掉 cursor,從第一頁重來。收到 400 ct.invalid_cursor 也一樣處理。切換每頁筆數時可以沿用目前的 cursor,因為 limit 每頁可以不同,但要清掉先前 cursor 的清單。
  5. 可以一個列表一個列表分批改。 沒有帶 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",之後送 cursorrecords_count 工具
Workspace ct.query(以及 teamsync data ct query 指令)page_mode: "cursor",之後送 cursoroperation: "count"
Python SDKtables.query(table_id, cursor=…),一開始就是 cursor 模式tables.count(table_id)
  • cursor 分頁不做筆數統計。 agent 工具不回 total 與 total_pages;MCP 與 workspace 在 cursor 模式下回 total: null。MCP records_query 與 workspace ct.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 分頁」。)
  • 「資料筆數」出現得很慢:問問這個數字是不是每次搜尋只算一次,而不是每翻一頁就重算。
  • 選指令很慢:問問選單是不是只載入名稱與說明(指令摘要),而不是每支指令的完整定義。
  • 檢查庫存或餘額很慢:問問餘額是不是存成一個數字,由每次異動的指令一併更新,而不是每次都把過去所有的異動加總。
  • 某張表只要依某個欄位篩選就一直很慢:請告訴維運人員(負責架設與維護這套系統的人)是哪張表的哪一欄,由他們決定要不要建立索引(為那個欄位建立的快速查找捷徑)。
Last updated on