vatt'ghern jaskier's ballads

同一條 react/react 的星數曲線,舊做法要約 2,511 次請求,新端點約 24 次,回應裡也沒有任何一個人的名字。省下來的不是回應大小,而是資料庫先做哪一步。

星數曲線:從 2,500 次請求到 24 次

GitHub 在 2026 年 10 月 14 日的工程部落格發了一篇文章,署名 Taylor Cuilty 與 Sarita Iyer,講的是 repository 的 star 歷史端點怎麼重做。起點是一個安全問題。文章說身分資料能被大量蒐集,「bad actors started scraping public stargazer lists for spam and phishing campaigns」,意思是有人去爬公開的 stargazer 清單,拿去做垃圾訊息與釣魚。GitHub 的處理方式,是把 stargazer 與 watcher 清單限縮給 repository 的管理員與協作者。

限縮擋住了爬蟲,也擋住了另一批不相干的讀者。文章點名 Star History 這類工具,它們要的是「counts and dates, not usernames or other user data」,只要數量與日期,不要使用者名稱。這些工具原本靠翻 stargazer 清單來畫曲線,清單關起來之後,需求還在,路沒有了。所以 GitHub 要做的不是把舊端點修快一點,而是給這個用途一個新的、不含任何人名的端點。標題裡的 2,500 與 24 就是新舊兩條路的請求數。這篇照結構拆開來看:舊路貴在哪裡,兩個被淘汰的方案各輸在哪裡,上線的設計怎麼運作,以及文章自己列出、還沒解掉的限制。

一頁一百人,一條曲線 2,511 次請求

舊做法的成本很好算。文章舉的例子是 react/react,約 251,000 顆星,要建出完整的星數曲線,「took about 2,511 requests」,約 2,511 次請求。原端點是一頁一頁回傳 stargazer,每頁最多 100 人。251,000 除以 2,511 約等於 100,兩個數字對得上。請求數跟星數成正比,每多 100 顆星就多一次請求,而每次搬回來的,是一筆一筆的個人記錄,曲線要用的卻只有每天的數量。

下面第一張圖把這個關係做成可調的。上面的滑桿調星數,下面的滑桿調歷史長度,單位是週,兩條橫棒是各自需要的請求數,座標軸是對數。舊端點的算式是星數除以 100 再進位;新端點的算式是歷史週數除以 30 再進位,30 是文章說的單一回應上限。預設停在 251,000 顆星、700 週,算出來是 2,510 次與 24 次。文章的數字是約 2,511 次與約 24 次,差的那一次,合理的推測是進位與實際分頁細節造成的。700 週不是文章給的數字,我只是挑了一個能對上 24 次的歷史長度,文章告訴我們的只有結果。

拖曳兩個滑桿,比較兩種端點的請求數 · 每頁 100 人 vs 每頁 30 週

repository 星數251,000
歷史長度(週)700
舊做法:翻 stargazer 清單,每頁 100 人 2,510 次 新做法:每頁 30 週的每日計數 24 次 1 10 100 1,000 10,000 畫出整條曲線所需的請求數(對數座標)

星數 251,000 ÷ 100 進位 = 2,510 次;歷史 700 週 ÷ 30 進位 = 24 次。舊做法約為新做法的 105 倍。

每頁 100 人與每回應最多 30 週來自文章;700 週是為了對上 24 次而設的示意值,算式為進位後的整除。

拖動時值得看的是兩條棒的反應方向。把星數從 251,000 拉到 1,000,000,上面那條跟著長約四倍;下面那條動也不動,因為它根本不看星數,只看歷史有多長。把歷史週數拉長,下面那條才會變,而且每多 30 週才多一次請求。這是新設計最基本的性質:請求數的決定因素,從集合有多大換成了時間軸有多長。集合的大小由別人的行為決定,時間軸每週只長一週。

請求數只是呼叫端的帳,資料庫那邊還有另一筆。文章說分頁實作有個 bug,「every request also counted all of those stars」,每個請求除了取那一頁,還要把所有的星數過一遍。限縮存取前的那一週,記錄顯示近 37,000 個這類 count 查詢慢到留下記錄,平均接近 3 秒;資料庫的保護機制砍掉了超過 1,000 個,文章說這促成了請求逾時。把兩筆帳疊起來,舊路對大 repository 的傷害是乘法:N 次請求,每次都帶一次對全部星數的 count。這個乘法是我的推論,文章給的是兩邊各自的數字,沒有把它們相乘。

這組數字還能再算一步。近 37,000 個慢查詢,每個平均接近 3 秒,加起來大約是十一萬秒,折成小時約三十幾小時的資料庫時間。這是限縮前那一週的總量。文章說的是「nearly」,所以這是上限附近的粗估,而且只算了留下記錄的慢查詢,沒記錄到的不在內。

回應變小,查詢沒有變便宜

團隊先考慮的兩個方案,都從少回一點資料出發。文章後來把教訓濃縮成一句:「A smaller response does not always mean a cheaper query.」回應小,不代表查詢便宜。這句話要放在下面兩個落選的方案後面讀,才看得出分量。

第一個方案是保留每顆星一筆事件,但把身分拿掉。這解決了隱私,沒解決請求數。文章的理由是呼叫端仍然得「paginate through and process potentially many thousands of individual records」,也就是翻過並處理可能上千上萬筆個別記錄。換句話說,瓶頸在呼叫端要走多少頁,拿掉名字不會讓頁數變少。這個方案對曲線用途來說,等於把舊路原樣留著,只換了欄位。

第二個方案更接近最後的答案:先聚合,再分頁。回傳的單位從每顆星變成每天的星數,再用 LIMIT 與 OFFSET 切頁。它的問題出在順序。文章說明,LIMIT 與 OFFSET 要等資料庫把每個 bucket 分組、排序完才套用,所以結論是「Returning fewer buckets would shrink the JSON response, but it would not reduce the expensive grouping and sorting」。少回幾個 bucket,只會讓 JSON 變小,不會讓昂貴的分組與排序變少。分頁參數只管輸出,不管輸入。

文章也估了這樣讀完整段歷史要付的成本:「reading the full history that way would have meant examining roughly 6 million rows」,約 600 萬列。對照前面 react/react 的 251,000 顆星,600 萬是它的二十幾倍。合理的推測是,這個數字來自 24 次請求各自把整段歷史掃一遍,24 乘 251,000 約 602 萬;文章沒有寫出這個算式,請當作反推來讀。

到這裡文章給了三種不同的成本數字,量的東西各不相同。2,511 次請求是呼叫端要走的條數;近 37,000 個慢 count 查詢、平均近 3 秒,是資料庫單次查詢的耗時;600 萬列是資料庫為了讀完整段歷史要檢視的總量。一個方案可以在其中一項上變好,另外兩項原地不動,這也是為什麼前面兩個落選的方案都只改善了一個維度。評估端點設計時,把這三本帳分開記,比只看回應多大有用。

下面這張圖把兩種做法的工作順序並排。上排是落選的,分頁在最後一步;下排是上線的,範圍篩選在第一步。兩排都是一次只回一頁,差別只在回那一頁之前,資料庫還要先做多少事。

被淘汰:先聚合,再分頁 全部的星 依日分組 排序 LIMIT / OFFSET 取一頁 文章所說昂貴的分組與排序,每個請求都要做 讀完整段歷史約 600 萬列 上線:先篩時間範圍,再分組 page 換成 created_at 範圍 只取範圍內的星 分成每日計數 每顆星約被檢視一次
600 萬列與每顆星約檢視一次都是文章的說法;兩排的方塊順序是依文章對兩種做法的描述整理,不是實際的查詢計畫。

分頁常被當成控制回應大小的工具,但它在查詢裡的位置,才決定它省不省工作。放在尾端,它只截斷輸出,前面的分組與排序一個都沒少;放在前端,它變成輸入的篩選條件,後面每一步處理的資料都跟著縮小。同一個 page 參數,兩種位置,照前面的反推,成本差了二十幾倍。

把四種設計擺在同一張表裡,會看到每一種把成本放在誰身上。

四種設計各自把成本放在哪裡,內容整理自文章對各方案的描述。
做法呼叫端的成本資料庫的成本結局
原本的 stargazer 清單react/react 約 2,511 次請求每個請求還多數一次所有的星清單改為只開放給管理員與協作者
每顆星一筆事件,去掉身分仍要翻過可能上千上萬筆記錄文章未細談淘汰
先聚合,再分頁回應變小分組與排序照做,約 600 萬列淘汰
依日曆週分頁約 24 次請求每顆星約被檢視一次上線,留有密集週等限制

第三列縮小了回應,卻沒有動到資料庫要做的分組與排序;第二列去掉了身分,卻沒有動到呼叫端要走的頁數。只有第四列同時動到兩邊,因為它換掉的是工作的順序。

用 created_at 範圍分頁,一頁三十週

上線的設計,文章一句話交代:「The design we shipped paginates star history by calendar week.」按日曆週分頁。端點把 page 與 per_page 換算成一段有索引的 created_at 範圍,先把落在範圍內的星篩出來,再分組成每日計數。每個回應最多包含 30 週,page 往上加,就往 repository 歷史更早的方向走。

順序反過來,才是重點。範圍篩選在分組之前,資料庫要處理的只剩這一段時間裡的星;每一頁對應一段不重疊的日曆週,把所有頁加起來,每顆星大約被檢視一次,文章的說法是「the database examines each star about once」。對 react/react,這是約 24 次請求,和舊路的約 2,511 次相比,少了大約一百倍,與前面滑桿預設值算出的倍數一致。呼叫端拿到的每一頁已經是每日計數,不必再自己從逐筆記錄彙整。30 週乘 24 次是 720 週,所以這個 repository 的歷史落在 691 到 720 週之間,換算大約十三年多。這個歷史長度是用 24 與 30 倒推的,文章沒有直接寫。

分頁參數的意義也跟著改了。舊式的 page 搭配 OFFSET,意思是跳過前面多少筆,要翻到第 N 頁,資料庫得先越過前 N-1 頁的份量。新的 page 是時間軸上的一個位置,換算成 created_at 的起訖,可以直接用索引定位。文章只說換算成有索引的範圍(indexed created_at range),沒有交代查詢計畫的細節,上面這段語意的轉換出自我的讀法。

下面的捲軸示意 page 對應的時間範圍。以 24 頁為例,拖動 page,看高亮的 30 週區段怎麼往更早的歷史移動。第 1 頁畫在最右邊,離現在最近。文章只說 page 增加就往歷史更早的方向走,並說一個回應包含「up to 30 weeks」,最多 30 週,所以最後一頁可以不滿。

拖曳 page,看它對應哪一段 30 週 · 以 24 頁為例

page1
page = 1,現在往回數第 1 到 30 週 第 1 頁 歷史更早 現在 每個方塊 = 一頁 = 最多 30 週,page 越大越往過去

page = 1 涵蓋現在往回數第 1 到 30 週。

每頁最多 30 週與 page 往歷史更早的方向增加來自文章;24 頁、第 1 頁在最右邊是示意與解讀。

圖上最容易忽略的是,每一頁的寬度是固定的 30 週,不是固定的星數。這個選擇讓每次請求涵蓋的期間有了上限,下一節會看到,它沒有替星數設上限。

回應的形狀也不是新發明。文章說它「follows the same shape as the weekly commit activity endpoint」,沿用 weekly commit activity 端點的格式,那個端點是 GET /repos/{owner}/{repo}/stats/commit_activity。用過那個端點的人,大概少學一種格式。

上線之後的三個結果

文章給了兩個百分比。資料庫處理的 stargazer 請求少了約 60%,count 查詢花的總時間少了約 80%。兩個都是資料庫端的量測,不是呼叫端的體感。

兩者的差距值得停一下。請求少 60%,count 時間卻少 80%,降幅比請求量更大。合理的推測是,省掉的不只是請求的條數,每條請求附帶的那次昂貴的 count 也跟著變輕了;文章沒有拆解這 20 個百分點從哪裡來,所以只能停在推測。另一點要分開看:60% 是資料庫整體處理的 stargazer 請求量,和前面 react/react 的 2,511 對 24 不是同一種數字。後者是單一條曲線的請求數,前者是整批流量的總量,兩者不能互相換算。

第三個結果來自另一個改動。GitHub.com 上的 stargazers 頁面改成使用 repository 的快取星數,文章說那個頁面原本每週有數百個慢查詢,改完降到零。這一項靠的是頁面不再每次重新數,跟新端點無關,所以不要跟前兩個百分比加在一起讀。

文章自己留下的帳

文章沒有把新端點寫成沒有缺點。它列了一個剩下的問題:「The time-range design limits each request to a defined period, but it does not limit the number of stars within that range.」時間範圍限制了每次請求涵蓋的期間,卻沒有限制期間內的星數。星數異常多的一週,查詢仍然可能慢。文章另外說,「calendar boundaries, removed stars, and unusually dense weeks still affect what it can return」,日曆邊界、被移除的星與異常密集的週,仍然會影響端點能回傳什麼。

這個限制和前面的得失是同一件事的兩面。舊路的請求多,但每個請求的範圍固定在一頁;新路的請求少,換來的是單次請求的範圍交給時間決定。星數增長得快的 repository,每週的星數也會跟著變多,同樣的 30 週視窗裡裝的星就不一樣。文章把這個風險明白寫出來,等於承認時間範圍只是一個代理的上界,不是工作量的上界。

文章對後兩項沒有再展開,這裡也不多猜。能從結構上說的只有第一項:上界綁在時間上,而時間和資料量沒有固定的比例。舊設計每次請求處理的是上限固定的一頁,請求很多;新設計每次請求涵蓋的是長度固定的期間,請求很少,但單次請求的工作量會隨那段期間的星數浮動。換個說法,很多個大小相近的請求,變成了很少個大小不一的請求。

甲:一次請求涵蓋的期間 乙:一次請求涵蓋的期間 一次請求,工作量小 同樣一次請求,工作量大 示意圖,每根長條是一週的星數,高度不代表任何實際 repository
兩個視窗等寬,各只算一次請求;上界綁在時間,不綁在星數。

文章把這點明寫成取捨,沒有藏在實作裡,並以「Be clear about the tradeoffs that remain」作為收尾教訓之一,要把留下來的取捨說清楚。對 API 來說,這些限制屬於契約,該寫進文件,免得呼叫端在生產環境才撞上。

大集合上的時間序列端點,可以帶走什麼

文章結尾列了四條教訓,原文依序是「A smaller response does not always mean a cheaper query.」「Start with what people are actually trying to do.」「Return only what the use case needs.」「Be clear about the tradeoffs that remain.」第一條前面已經看過,最後一條也在上一節談過。中間兩條講的是動手的順序,先問用途,再決定回什麼。

Star History 要的是每天幾顆星,所以回應裡只需要日期與數量,日曆週剛好是個自然的分頁單位。文章把回應做成每日計數,粒度停在用途要的那一層,身分與逐筆記錄都沒有留下。如果一開始問的是怎麼讓 stargazer 清單更安全,合理的推測是設計會停在去掉身分的清單,也就是落選的第一個方案。

以下幾個檢查是我從這篇文章延伸出來的,文章本身沒有列。先看分頁參數落在查詢的哪一步:LIMIT 或 OFFSET 出現在分組與排序之後,只會減少輸出;能不能把 page 換成對索引欄位的範圍條件,是最可以直接搬走的判斷。文章沒有提到任何具體的檢查工具。接著替請求數找一個不隨資料量成長的決定因素。這裡是時間軸長度,換成別的場景,可能是版本數或月份數,要看那個維度有沒有索引、有沒有自然的邊界。時間序列適合這招,因為時間本身就是順序,通常也建了索引,後面這點是一般經驗,不在文章裡。

分頁單位綁在期間上,代價是密集期。文章承認異常密集的週仍然會慢,所以資料若有尖峰,尖峰對應的那次請求就是整條曲線裡最重的一次。緩解方式文章沒有討論,這裡只提醒設計階段就要問,別等慢查詢的告警來問。

最後看每次請求裡有沒有藏著數全部的呼叫。舊路的 bug 就是每個請求都把全部的星數一遍;stargazers 頁面的慢查詢從每週數百降到零,靠的是改用快取的星數。你的端點若也有這樣的呼叫,先問它是否真的需要即時。文章只報告了那個頁面的結果,沒說快取怎麼失效,這個建議是從結果往回推的。

把這幾件事合起來,是一個動手前的順序:先弄清楚呼叫端要拿資料做什麼,再決定回應裡放什麼粒度,然後看分頁參數能不能變成對索引的範圍條件,最後把留下的取捨寫進文件。GitHub 的這次重做,每一步都能在文章裡找到對應的段落,從 stargazer 清單的限縮,到 24 次請求,到密集週這個還沒解掉的限制。

What this enables:一個只回日期與數量的端點,讓 Star History 這類只需要曲線的工具重新拿得到資料,資料庫每顆星只被檢視一次左右;代價是密集週、被移除的星與日曆邊界仍會影響回傳內容,文章把這些留下來的取捨當成契約的一部分。