
銀行對帳單只印日期不印時間,同一天存款跟提款的順序就靠猜的。系統要是把「先存後提」排成「先提後存」,帳面會直接顯示透支——不是錢不見了,是順序錯了。
技術環境
Node.js 後端,TypeScript。交易表 trades 在 PostgreSQL,欄位 executed_at 為 TIMESTAMPTZ,寫入端已改為帶時分秒的完整時間戳。帳本重建(ledger replay)是離線批次 job,讀取交易表後重新產出持倉快照與風控事件,再餵給下游報表與 API。Replay 本身是 async;但下游的所有同步判斷(風控凍結、對帳單、稅務期間切分)都依賴 replay 輸出的事件順序。這條依賴鏈的特性:寫入端正常、讀取端被截斷,跨 commit 各自測試 pass,但 replay 整體順序錯。
這次碰到的問題,機制幾乎一模一樣。
凍結,但記錄明明是對的
某幣別的買單持續凍結,錯誤訊息是「賣出未持倉」。回頭翻原始交易記錄,02:32 買入、03:20 賣出,先買後賣,沒有問題。帳本重建之後,順序反了——系統看見的是先賣後買,當然判成違規賣空。
直覺會先懷疑寫入格式。資料庫欄位存的是完整時間戳還是只有日期?確認一查,寫入端早就改過了,存的是帶時分秒的完整格式,這條路是死路。
錯誤傳染鏈(時序)
交易服務 Ledger Replay Job PostgreSQL 風控服務 | | | | |── INSERT buy 02:32 ───────────────────>| | |<─ 寫入完整 timestamp ✓ ────────────────| | | | | | |── INSERT sell 03:20 ──────────────────>| | |<─ 寫入完整 timestamp ✓ ────────────────| | | | | | | |── SELECT * ──────────>| | | |<─ rows w/ full ts ─────| | | | | | | |── .slice(0,10) 對每筆 ──| ← 時間被截掉 | | |── sort 字典序 ─────────| ← sell < buy | | | | | | |── emit events [sell, buy] ─────────────────>| | | | | | | 風控:賣在買前 → 凍結 ✗ | | |────────────────────────────────────────────| DB state: 兩筆順序正確 ✓ 風控認知: 違規賣空 → 凍結 ✗ ← 落差在這
關鍵節點:寫入端時間戳完整,但 replay 的讀取端用 .slice(0,10) 把時間精度抹平,字典序 tiebreaker 把賣單排到買單前面。DB 寫入正確,replay 輸出錯誤,風控基於錯誤輸出凍結。
讀取端多做了一件事
問題在 replay 邏輯的讀取端。帳本重建時,程式為了比對日期範圍,對每一筆時間戳都做了 .slice(0,10),只取前十個字元——也就是 YYYY-MM-DD。小時、分、秒全部抹掉。
同一天內的多筆交易,時間精度消失之後,排序鍵全部相同。這時 字典序 tiebreaker 介入:賣單永遠排在買單前面。02:32 的買入跟 03:20 的賣出,重建後變成先賣後買,跟原始記錄完全相反。
寫入端修正了,讀取端又截回去,等於修了一半。
Code 對照:修法前後
修法前:replay 讀取交易後,把 timestamp 轉字串再切片,丟進陣列排序。字串比對在時間精度歸零後成了排序的決定性 tiebreaker——而賣單業務代碼字串上常排前。
// ledger-replay.ts — replay() 內部
const rows = await db.query(
`SELECT id, side, executed_at FROM trades
WHERE executed_at >= $1 AND executed_at < $2`,
[fromDate, toDate]
);
// 為了「過濾日期範圍」+「排序」共用同一欄位 — 把時間切掉
const sortable = rows.map(r => ({
...r,
sortKey: r.executed_at.toISOString().slice(0, 10), // ← 問題在這裡
}));
sortable.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
// 全部 sortKey 變 "2026-08-15",tiebreaker 落到 side 字串比對
// sell < buy → 賣單永遠在買單前
修法後:時間範圍用 SQL 端 ISO 字串比較(Postgres 對 TIMESTAMPTZ 的字串比較能正確處理時區),排序用 Date.parse() 取 epoch 毫秒數值比對——數值比較不受字串截斷影響。
// ledger-replay.ts — replay() 內部
const rows = await db.query(
`SELECT id, side, executed_at FROM trades
WHERE executed_at >= $1::timestamptz
AND executed_at < $2::timestamptz`,
[fromDate, toDate]
);
// 範圍比對交給 SQL;排序用 epoch 毫秒,保留時分秒精度
const sorted = rows
.slice() // 不汙染原陣列
.sort((a, b) =>
Date.parse(a.executed_at) - Date.parse(b.executed_at) // ← 修正
);
// 02:32 與 03:20 的先後由 epoch 數值決定,不靠字串編碼
該被隔離的側效應類型
- 風控凍結規則:對持倉做賣單檢查;吃 replay 後的事件順序。順序錯誤直接誤判違規,影響使用者下單。
- 對帳單輸出:同一日多筆交易會依序列出;順序錯就讓使用者看到顛倒的紀錄。
- 稅務期間切分:跨日交易的歸屬期間依執行時間;切日期字串後跨日交易可能被誤歸。
- API 給下游服務的順序:別的服務吃 replay 事件串流;訂閱者若假設時間精度為唯一排序依據,會拿到錯的事件。
- 報表聚合:每小時/每日彙總依賴事件落入正確 bucket;精度丟失後同日事件的先後會在聚合時雜訊化。
- 第三方同步上傳:券商/交易所同步介面收到 replay 後的持倉狀態;順序錯可能觸發對帳失敗。
- 客服與申訴查詢:客服查特定日的交易紀錄;順序錯讓使用者查到的內容與實際帳本對不上。
- 歷史回測 (backtest):回測策略仰賴事件入帳的精確時間;截斷會讓回測結果與實盤偏離。
判斷標準:任何邏輯吃 replay 後的事件順序、且它的失敗會讓使用者看到「帳面錯誤」或「操作被誤擋」,它的排序鍵就不能被截斷——時間排序與日期篩選應該分開兩條路,不要混在同一個欄位值上。
為什麼沒立刻看出來
這類 bug 容易被拖延診斷,原因是它跨了兩個 commit。寫入格式的修正先進,讀取端的 .slice(0,10) 在另一次改動裡,各自的單元測試都 pass,整合測試沒有專門跑「同日多筆同類交易」的 case。
只要測試資料每天頂多一筆,字典序排序的破綻永遠不會出現。Replay 驗證也容易只對最終餘額,不對中間狀態的交易順序,錯誤就這樣靜靜地藏著。
確認方式
最快的驗法:重建帳本後,找出同日有多筆買賣的幣別,把重建後的交易順序跟原始交易表的時間戳逐筆比對。不一致,就是讀取端截斷的問題。
修法直接:移除所有 .slice(0,10),改用 Date.parse() 取數值比較,跑一次完整 replay,看錯誤數是否歸零。數值比較不依賴字串格式,02:32 跟 03:20 的先後不會被抹平。
留給未來的一件事
凡是涉及事件順序的 replay 邏輯,排序鍵必須保留完整時間戳並用數值比較。為了方便做日期篩選而截斷時間,是兩個不同的操作,不應該混在同一個欄位值上動刀。
測試要專門覆蓋「同日多筆同類交易」這個 case。它是字典序 tiebreaker 最好的照妖鏡,平時靜默、一碰就原形畢露。
— 邱柏宇
The Timestamp Was There. The Replay Cut It Off.
Imagine a bank statement that prints only dates, not times. On the same day: one deposit, one withdrawal. If the system sorts them into “withdrawal first, deposit second,” the account shows overdraft — the money is fine, the order is wrong.
This bug worked exactly like that.
Technical Environment
Node.js backend, TypeScript. The trades table lives in PostgreSQL with an executed_at TIMESTAMPTZ column; the write side has long stored full ISO timestamps with hours, minutes, and seconds. Ledger replay is an offline batch job — it reads the trades table, reconstructs a position snapshot and a risk event stream, and feeds both into downstream reporting and APIs. Replay itself runs async, but every synchronous check downstream (risk freeze, statement export, tax period boundary) consumes the replayed event order. The defining property of this dependency chain: the write side is correct, the read side silently truncates, each commit ships passing tests, and the order error only appears when the full replay runs end to end.
Frozen Buy Orders, Clean Transaction Log
A buy order for a specific token kept getting frozen. The error: “selling without a position.” The raw transaction log said otherwise — a buy at 02:32, a sell at 03:20, in the right order. After ledger replay, the sequence flipped. The system saw a sell before a buy and flagged it as short-selling.
First instinct is to check the write side. What format does the database store? Full timestamp, or just a date? The write side had already been fixed — full timestamps with hours, minutes, and seconds. That wasn’t it.
Error Propagation Sequence
Trade Service Ledger Replay Job PostgreSQL Risk Service | | | | |── INSERT buy 02:32 ──────────────────>| | |<─ full timestamp written ✓ ───────────| | | | | | |── INSERT sell 03:20 ─────────────────>| | |<─ full timestamp written ✓ ───────────| | | | | | | |── SELECT * ────────>| | | |<─ rows w/ full ts ───| | | | | | | |── .slice(0,10) on every row ── ← truncates | |── lexicographic sort ───────── ← sell before buy | | | | | |── emit events [sell, buy] ──────────────>| | | | | | | Risk: sell before buy → freeze ✗ | |────────────────────────────────────────| DB state: two rows in correct order ✓ Risk view: violation → freeze ✗ ← gap is here
Key node: the write side stores full timestamps, but the replay read path strips them with .slice(0,10); once the sort keys collapse to the same date string, lexicographic tiebreaking silently moves sell orders ahead of buy orders. The DB is right, the replay output is wrong, and the risk layer freezes based on the wrong output.
The Read Side Cut It Back
The replay logic was doing something quiet. To compare date ranges during ledger reconstruction, the code called .slice(0,10) on every timestamp — keeping only the first ten characters, YYYY-MM-DD, and throwing away the time component entirely.
With all same-day transactions now sharing an identical sort key, a lexicographic tiebreaker took over. Sell orders sorted before buy orders. The 02:32 buy and 03:20 sell came out reversed. The write-side fix had been real; the read side just undid it one line later.
Code Diff: Before and After
Before: replay queried rows, converted timestamps to strings, sliced the first 10 characters, and sorted by those short strings. Once the time precision disappeared, string comparison became the tiebreaker — and sell-side business codes often sort ahead of buy-side codes in lexicographic order.
// ledger-replay.ts — inside replay()
const rows = await db.query(
`SELECT id, side, executed_at FROM trades
WHERE executed_at >= $1 AND executed_at < $2`,
[fromDate, toDate]
);
// "date-range filter" + "sort" share one column — slice it
const sortable = rows.map(r => ({
...r,
sortKey: r.executed_at.toISOString().slice(0, 10), // ← the bug
}));
sortable.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
// every sortKey is "2026-08-15"; tiebreaker falls to side string compare
// sell < buy → sells always ordered before buys
After: the date range check stays on the SQL side (Postgres handles TIMESTAMPTZ strings correctly across timezones), and the in-memory sort uses Date.parse() for epoch-millisecond numeric comparison — numeric compare ignores string format entirely.
// ledger-replay.ts — inside replay()
const rows = await db.query(
`SELECT id, side, executed_at FROM trades
WHERE executed_at >= $1::timestamptz
AND executed_at < $2::timestamptz`,
[fromDate, toDate]
);
// range goes to SQL; sort uses epoch ms, preserving hh:mm:ss
const sorted = rows
.slice() // don't mutate the original array
.sort((a, b) =>
Date.parse(a.executed_at) - Date.parse(b.executed_at) // ← fix
);
// 02:32 vs 03:20 ordered by numeric ms, not by string encoding
Side Effects That Should Be Isolated
- Risk freeze rules: check positions on every sell; consume replayed event order. Wrong order produces false-positive violations and blocks user trades.
- Statement export: lists same-day transactions in order. Reversed order means the customer sees the wrong transaction history.
- Tax period boundary: assigns trades to fiscal periods by execution time. Sliced dates mis-attribute cross-day trades.
- Downstream API event stream: other services consume the replayed event stream. Subscribers that assume time precision is the only ordering key receive reordered events.
- Aggregated reporting: hourly/daily rollups bucket events by time. Lost precision jitters same-day events across buckets.
- Third-party sync upload: broker / exchange sync receives the replayed position state. Wrong order can trigger reconciliation failures.
- Support and dispute queries: customer support pulls a specific day's trades. Mismatched order makes the answer disagree with the on-disk ledger.
- Historical backtest: strategies depend on precise event timing. Truncation moves backtest results away from live behavior.
Rule of thumb: any logic that consumes replayed event order, and whose failure causes the user to see a wrong balance or a wrongly blocked action, must not have its sort key truncated. Date range filtering and sort precision are two separate concerns; do not amputate the value to serve both.
Why It Took a While to See
This kind of bug hides in the gap between two commits. The timestamp format fix landed first. The .slice(0,10) lived in a different changeset. Each passed its own unit tests. No integration test covered “multiple same-type transactions on the same day,” because most test datasets had at most one trade per day per token. The Date.parse() issue simply never surfaced.
Replay validation that checks only final balances — not intermediate ordering — will miss this entirely. The error can sit undetected until a day with enough trading volume to produce two same-direction trades within a few hours of each other.
The Quickest Confirm
After rebuilding the ledger, find any token with multiple buy/sell trades in the same calendar day. Compare the reconstructed order against the raw transaction table’s timestamps. If they don’t match, the read-side truncation is the cause.
The fix: remove .slice(0,10), replace with Date.parse() for numeric comparison, run a full replay, verify the error count drops to zero. Numeric comparison doesn’t care about string format — 02:32 and 03:20 stay in the right order.
One Thing Worth Carrying Forward
Any replay logic that depends on event ordering must sort by full timestamps using numeric comparison. Date-range filtering and sort-key precision are two separate concerns; truncating the value to handle one will quietly break the other.
Test coverage should always include a “multiple same-type trades on the same day” case. It’s the exact scenario where a lexicographic tiebreaker breaks down — invisible in sparse data, immediate in production volume.
— 邱柏宇
延伸閱讀
- 沙盒裡的員工已經開始攻擊隔壁公司了
- 降速,才能活下去:台灣韌性演練的反直覺邏輯
- 台灣靠晶片出名,但每年有五萬個越南年輕人選的是別的
- 同一週,三個台灣品牌用三種方式進日本
- 養鮭魚的鐵路公司,用湧水重新定義地方的價值
https://justfly.idv.tw/s/tQcVFDE