DeFi 交易所 Bullet 如何把分析延遲從 90 分鐘壓到 5 秒內
clickhouse.com/blog · 2026-07-15
Bullet 是建構在 Solana 上的一個應用專屬 rollup,以 Rust 撰寫,提供衍生性商品交易、現貨交易與借貸服務,交易結算延遲可達 p50 500 微秒、p99 650 微秒,使用者可自行託管資產而不必依賴中心化交易所。該團隊近日在 ClickHouse 官方部落格分享,他們把分析管線從 Databricks 批次系統換成 ClickHouse Cloud,將排行榜等關鍵指標的更新延遲從原本 1 到 2 小時壓到 5 秒以內。
原本的問題
Bullet 原本用 Databricks 每 60 分鐘跑一次批次分析,光是排行榜計算本身就要 20 到 30 分鐘執行完畢,使用者看到的數字最多可能是 90 分鐘前的狀態。資料新鮮度不足之外,Databricks 的查詢延遲落在 10 到 15 秒,前端根本無法直接呼叫。
為了繞開這個限制,團隊被迫額外維護一套 DynamoDB 服務層,把 Databricks 算出來的結果每小時批次寫入快取再供前端讀取。這種雙系統架構本身就帶來額外的維運負擔:兩套管線要保持同步,任何 schema 變動都要改兩個地方。
採用的方法
新架構的資料流是:Bullet 的 rollup 定序器產生事件,經 Rust 撰寫的 WebSocket 索引器送進 Kafka(AWS MSK),再由 Redpanda Connect 做分類與路由,最終寫入 ClickHouse Cloud 的資料表,同時並行備份到 S3 Glacier。整條管線經過壓力測試可承受每秒 300 萬筆交易且零遺失,事件從定序器送出到進入 ClickHouse 通常在一秒內完成。
每一筆交易都帶有唯一遞增的事件編號與交易雜湊,寫入 ReplacingMergeTree 表後,ClickHouse 會以時間戳為判斷依據自動保留最新版本,讓整條管線天生具備冪等性,重送或亂序到達的事件都不會造成重複計算。排行榜則改用 refreshable materialized view 每 10 分鐘重算一次,內部以六個 CTE 搭配 window function 跨多張表 join,並在計算損益排名時刻意排除已入金的本金部位。
資料表的設計也從單一雜亂資料庫,改成依事件類型分表,每種事件各自的排序鍵與索引都能對應到最常見的查詢型態,而不是為了通用性犧牲效能。
實際效果
| 指標 | Databricks(舊) | ClickHouse Cloud(新) |
|---|---|---|
| 查詢延遲 | 10-15 秒 | 毫秒等級 |
| 資料新鮮度 | 1-2 小時 | 5 秒內 |
| 排行榜更新頻率 | 60 分鐘一次,計算耗時 20-30 分鐘 | 每 10 分鐘,可視需求即時觸發 |
團隊表示三個環境合計處理的資料量成長了約千倍,月費用卻維持相近水準。這套管線目前支撐三類場景:交易紀錄、損益圖表與稽核紀錄等使用者可見功能;24 小時交易量、排行榜、推薦獎勵等即時分析;以及每分鐘一次的系統快照送進 Grafana 儀表板,搭配 Slack 與 PagerDuty 告警。
原始來源:ClickHouse Blog
@clickhouse/rowbinary:把函式庫本身變成一座剖析器編譯器
clickhouse.com/blog · 2026-07-14
ClickHouse 團隊在部落格發表了 @clickhouse/rowbinary,一套供 Node.js 讀寫 ClickHouse RowBinary、RowBinaryWithNames、RowBinaryWithNamesAndTypes 格式的函式庫。這套函式庫的特別之處不在於效能本身,而在於它同時把自己包裝成一份可以教會 coding agent「生成專屬剖析器」的 Agent Skill。
設計動機
RowBinary 是一種小端序、以 LEB128 變長整數編碼、逐列無額外開銷的二進位格式,但每種葉節點型別都有各自的讀取方式,Nullable、Array、Map、Tuple、LowCardinality 這類容器型別又可以任意巢狀組合。多數應用因此退而求其次改用 JSON,但 JSON 會讓超過 Number.MAX_SAFE_INTEGER 的 UInt64 數值悄悄失真;若寫成通用的型別分派剖析器,雖然好維護,執行期的動態分派又拖慢效能。
核心機制
ClickHouse 沒有選擇傳統的 codegen 編譯器,而是把「產生程式碼」這件事拆成三種 agent 可以直接讀取、重組的素材:第一是大量加註解的最小讀取原語,每種葉節點型別各自一個可單型化的小函式;第二是 SKILL.md,描述如何把這些原語組裝成針對特定 query 的專屬剖析器;第三則是在推論當下由 agent 即時生成程式碼,而非預先編譯。
以一個含 UInt8、UUID、Decimal64(2)、Enum8 欄位的訂單 schema 為例,通用寫法要逐欄呼叫對應的 reader:
export const readOrderRow: Reader<OrderRow> = (s) => ({
id: readUInt8(s),
uid: formatUUID(readUUID(s)),
price: readDecimal64(2)(s),
status: readInt8(s),
});agent 生成的專屬版本則把整列的欄位長度算成常數 offset,只做一次邊界檢查,並用查表法直接格式化 UUID:
export const readOrderRowFast: Reader<OrderRow> = (s) => {
const { buf, view } = s;
const o = advance(s, 26);
const id = buf[o]!;
const uid = formatUUIDTable(buf.subarray(o + 1, o + 17));
const price: DecimalValue = [view.getBigInt64(o + 17, true), 2];
const status = view.getInt8(o + 25);
return { id, uid, price, status };
};註解裡明確標出哪些軸線可以安全修改,例如 buffer 所有權、要用 BigInt 還是 number、Date 的映射方式、Decimal 精度處理與 Array 的具現化策略,讓 agent 在生成程式碼時有明確邊界可循。
效益
官方測出 agent 生成的專屬剖析器,相較於一般組合式 reader 快 1.5 到 3.4 倍:寬整數的財務帳本場景快 1.55 倍、IoT 遙測(浮點/整數混合)快 2.46 倍、固定寬度的訂單 schema 快 3.41 倍。整體而言,RowBinary 又比 JSON 快 2.5 到 3.3 倍,主因是指標運算取代了容易分支預測失誤的 tokenizing。
生成一支專屬剖析器的成本約為每次 0.20 美元(啟用 prompt caching 後),未快取時上限約 0.72 美元,輸入約 23 萬 token(多數命中快取)、輸出約 2100 token。函式庫本身提供 parseRowBinary(...) 供一般情境使用,並依賴 @clickhouse/datatype-parser 處理型別解析;安裝與註冊方式為:
npm i @clickhouse/rowbinarynpx skills add ClickHouse/clickhouse-js --skill clickhouse-js-node-rowbinary
ClickHouse Managed Postgres 如何用 Huge Pages 削減每條連線的記憶體稅
clickhouse.com/blog · 2026-07-14
ClickHouse Cloud 團隊在其 Managed Postgres 服務中,分享了他們如何透過設定 Huge Pages 大幅壓低 PostgreSQL 在多連線情境下的記憶體開銷。Huge Pages(2MB 或 1GB)取代作業系統預設的 4KB 分頁,讓一筆分頁表項目就能對應到多達 262,144 倍的記憶體,藉此降低 TLB(Translation Lookaside Buffer)壓力,避免頻繁的分頁表查找造成延遲。
原本的問題
PostgreSQL 的 shared_buffers 快取,是由每個後端連線各自的分頁表個別映射的。以 4KB 分頁計算,一個 100GB 的共享快取,每個後端連線大約要多耗 200MB 的分頁表項目儲存空間;100 條連線就會吃掉 20GB RAM 純粹用於記憶體管理的簿記,而不是實際快取資料。這個「每連線稅」在連線數上升時會急遽惡化。
採用的方法
團隊把設定拆成三個步驟。第一步是在 Postgres 啟動前預留 huge pages,通常抓系統總 RAM 的 25%,對應 shared_buffers = 25% of RAM 的常見設定慣例:
echo 'vm.nr_hugepages = <N>' > /etc/sysctl.d/10-hugepages.conf
sync
echo 3 > /proc/sys/vm/drop_caches
echo 1 > /proc/sys/vm/compact_memory
sysctl --system第二步是強制啟用,把 huge_pages = 'on' 寫進設定,讓 Postgres 在拿不到 huge pages 時直接啟動失敗並丟出明確錯誤,而不是悄悄退回 4KB 分頁:
huge_pages = 'on'
huge_page_size = 0 # 系統預設,即 2MB
shared_buffers = <計算後的 kB 值>第三步是精確反推 shared_buffers 大小,用 postgres -D <data_directory> -C shared_memory_size 量出緩衝區描述子、WAL buffer、鎖定表等實際 segment 開銷,讓最終設定值剛好塞進預留的 huge pages 池中,不多也不少。
實際效果
團隊在 r7i.4xlarge(16 vCPU、128GB RAM)機型上,用 Postgres 16 搭配 32GB shared_buffers 做實測:
| 連線數 | 4KB 分頁 | 2MB Huge Pages |
|---|---|---|
| 0 | 48 MB | 5 MB |
| 25 | 789 MB | 18 MB |
| 100 | 3.06 GB | 58 MB |
| 200 | 6.12 GB | 111 MB |
換算下來,4KB 分頁每增加一條連線平均多耗 31.1MB,2MB huge pages 下每條連線只多耗約 0.5MB。100 個並發客戶端的吞吐量也從 373,083 TPS 提升到 418,087 TPS,約成長 12%。
要確認 huge pages 是否真的生效,而非悄悄退回 4KB 分頁,可以直接檢查執行期指標:
grep HugetlbPages /proc/<pid>/status若該欄位顯示接近零的數值,代表設定沒有生效,系統仍在用一般分頁運作。
原始來源:ClickHouse Blog