0

Tối ưu row read usage cho Cloudflare D1 database: Từ 1,2 tỷ dòng xuống còn 7000 dòng mỗi ngày

USDT/VND P2P Rate Monitor Tech Stack: · Cloudflare Workers + D1

USDT/VND P2P Rate Monitor là một side project nhỏ của mình. Ứng dụng này cứ 5 phút một lần, một Cloudflare Worker lấy giá P2P USDT/VND từ Binance, Bybit, OKX, Gate.io, Bitget, MEXC rồi ghi vào một database Cloudflare D1 duy nhất. Website, Chrome extension, worker cảnh báo và báo cáo Telegram hằng ngày đều đọc từ database này.

Dự án vốn chạy rất rẻ, nên khi dashboard D1 báo khoảng 1,2 tỷ rows read mỗi ngày thì mình khá bất ngờ. Bài này đi qua bốn vấn đề tạo ra con số đó. Với mỗi vấn đề, bài trình bày function liên quan, vì sao nó tốn, cách sửa và kết quả.


1. Bối cảnh

1.1 Hệ thống dùng database như thế nào

 mỗi 5 phút (cron)                           website / Chrome extension
        │                                              │
        ▼                                              ▼
 collectAndStoreRates()                     GET /p2p-usdt-vnd      (giá hiện tại)
   └─ với mỗi sàn trong 6 sàn:              GET /p2p-usdt-vnd/history  (biểu đồ, 1 request mỗi sàn)
        get<Exchange>P2PRate()              GET /vcb-rate/history  (đường tỷ giá ngân hàng)
          ├─ getLastRate()   ── ĐỌC ──┐                │
          └─ gọi API của sàn          │                │
   └─ INSERT mỗi sàn một dòng ─ GHI ─► D1: p2p_usdt_vnd_rates ◄── ĐỌC ──┘
                                                   ▲
                    worker-detect-signals (mỗi 30 phút), worker-daily-report (hằng ngày) ── ĐỌC
  • Cron ghi mỗi sàn một dòng sau mỗi 5 phút: 288 lần × 6 sàn ≈ 1.700 dòng mỗi ngày.
  • Endpoint giá hiện tại /p2p-usdt-vnd gọi lại chính các fetcher mà cron dùng, nên mỗi lượt xem trang cũng chạy các lệnh đọc database của chúng.
  • Lúc bắt đầu tối ưu, bảng chính có khoảng 600.000 dòng, cả database nặng 83,5 MB.

1.2 D1 tính phí như thế nào

Bên dưới D1 là SQLite, và D1 tính phí theo số dòng đọc (rows read), không phải số dòng trả về. Một query chỉ trả về 1 dòng nhưng phải quét 600.000 dòng để tìm ra nó thì bị tính đủ 600.000 dòng. Hạn mức hiện tại (theo trang pricing của Cloudflare):

Workers Free Workers Paid
Rows read 5 triệu / ngày 25 tỷ / tháng miễn phí, sau đó $0,001 / triệu dòng
Rows written 100.000 / ngày 50 triệu / tháng miễn phí, sau đó $1,00 / triệu dòng
Dung lượng tổng 5 GB 5 GB miễn phí, sau đó $0,75 / GB-tháng

Hai chi tiết quan trọng với bài này:

  • Index tốn chi phí ghi. Mỗi index cộng thêm một row written khi lệnh insert chạm tới cột của nó: một dòng ghi vào bảng và một dòng ghi vào mỗi index. Index cũng tính vào dung lượng lưu trữ.
  • Gói Free giờ là giới hạn cứng. Từ ngày 1/9/2026, query trên gói Free sẽ bị lỗi khi tài khoản vượt hạn mức trong ngày. 1,2 tỷ rows mỗi ngày gấp 240 lần hạn mức Free.

1.3 Bảng và index trước khi tối ưu

CREATE TABLE p2p_usdt_vnd_rates (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,   -- GMT+7, 'YYYY-MM-DD HH:MM:SS'
    exchange TEXT,                                  -- 'binance', 'okx', ...
    buy_rate REAL,
    sell_rate REAL
);
CREATE INDEX idx_timestamp       ON p2p_usdt_vnd_rates(timestamp DESC);
CREATE INDEX idx_timestamp_range ON p2p_usdt_vnd_rates(timestamp);
Index Cột Mục đích ban đầu
idx_timestamp timestamp DESC lấy "các dòng mới nhất" của mọi sàn
idx_timestamp_range timestamp query theo khoảng thời gian

Cả hai index đều chỉ có cột timestamp, và SQLite đọc được index theo cả hai chiều, nên index thứ hai không thêm được gì. Không có index nào bắt đầu bằng exchange. Đó chính là vấn đề chính.

1.4 Cách đọc query plan

Mọi phần bên dưới đều dùng EXPLAIN QUERY PLAN, lệnh cho biết SQLite sẽ chạy query như thế nào. Chạy nó trong D1 console hoặc bằng wrangler d1 execute. Các dòng cần để ý:

Dòng trong plan Ý nghĩa Chi phí
SCAN <table> đọc mọi dòng trong bảng đắt khi bảng lớn
SEARCH <table> USING INDEX <idx> (...) nhảy thẳng tới đoạn khớp trong index, rồi đọc dòng tương ứng trong bảng rẻ
SEARCH ... USING COVERING INDEX <idx> trả lời query chỉ bằng index, không đọc bảng rẻ nhất
USE TEMP B-TREE FOR ORDER BY gom các dòng khớp rồi sắp xếp trước khi trả về tăng theo số dòng khớp

2. Vấn đề 1: query "giá gần nhất" quét toàn bảng

Bối cảnh: getLastRate() làm gì

Mỗi sàn có một fetcher riêng trong backend-cf/src/exchanges/<exchange>.js, export dưới dạng get<Exchange>P2PRate(amountVnd, db). Order book P2P rất nhiễu: có quảng cáo spam và quảng cáo giá ảo. Vì vậy mỗi fetcher bảo vệ dữ liệu theo hai cách:

  1. Chống giá bất thường. So giá mới với giá gần nhất đã lưu của sàn đó, loại bỏ giá lệch quá 1% (0,5% với Bybit và Gate.io).
  2. Dự phòng. Nếu API của sàn lỗi hoặc timeout, trả về giá đã lưu gần nhất, để một sàn lỗi không làm hỏng cả lượt thu thập.

Cả hai đều cần giá gần nhất đã lưu, nên lần fetch nào cũng bắt đầu như sau:

// backend-cf/src/exchanges/okx.js (trước; các sàn khác có function y hệt)
export async function getOKXP2PRate(amountVnd, db) {
    const lastRates = await getLastRate(db, 'okx');
    // ... gọi API OKX, so với lastRates, lỗi thì trả về lastRates
}

async function getLastRate(db, exchange) {
    const result = await db.prepare(`
      SELECT buy_rate, sell_rate
      FROM p2p_usdt_vnd_rates
      WHERE exchange = ?
      ORDER BY timestamp DESC
      LIMIT 1
    `).bind(exchange).first();
    // ...
}

Tần suất chạy: một lần cho mỗi sàn ở mỗi lần cron (288 × 6 ≈ 1.700 lần/ngày), cộng thêm một lần cho mỗi sàn ở mỗi request tới /p2p-usdt-vnd.

Vì sao tốn

Query lọc theo exchange và sắp xếp theo timestamp. Một index chỉ dùng được bắt đầu từ cột ngoài cùng bên trái của nó. Cả hai index hiện có đều bắt đầu bằng timestamp, nên không index nào tìm được "các dòng của OKX". SQLite đành quét toàn bảng:

SCAN p2p_usdt_vnd_rates
USE TEMP B-TREE FOR ORDER BY

Nó đọc hết mọi dòng, giữ lại các dòng của OKX, sắp xếp rồi trả về một dòng. Mình đo lại trên production bằng cách ép SQLite chạy plan cũ (NOT INDEXED):

681.656 rows read và 680 ms để trả về 1 dòng.

(Con số này nhỉnh hơn số dòng trong bảng một chút; có vẻ bước sort cũng được tính vào.)

Với khoảng 1.700 lần gọi mỗi ngày từ cron cộng thêm lượt gọi từ endpoint giá hiện tại, tổng cộng là khoảng 1,2 tỷ rows read mỗi ngày, và mỗi lần gọi lại tốn hơn khi bảng lớn dần.

Cách sửa: covering index bắt đầu bằng exchange

-- backend-cf/migrations/0001_read_optimizations.sql
CREATE INDEX IF NOT EXISTS idx_rates_exchange_timestamp
    ON p2p_usdt_vnd_rates(exchange, timestamp, buy_rate, sell_rate);

-- idx_timestamp_range trùng với idx_timestamp; bỏ đi để chi phí ghi không tăng
DROP INDEX IF EXISTS idx_timestamp_range;

Index là một bản sao riêng, đã sắp xếp, của một số cột. Index này được sắp theo exchange trước, rồi tới timestamp:

idx_rates_exchange_timestamp  (sắp theo exchange, rồi timestamp)
exchange  timestamp            buy_rate  sell_rate
binance   ...                  ...       ...
binance   2026-10-04 16:05:54  ...       ...
bybit     ...
...
okx       2026-10-04 16:00:54  26039     26030
okx       2026-10-04 16:05:54  26037     26030   ◄─ "dòng OKX mới nhất" là cuối khối okx
...

Để tìm dòng OKX mới nhất, SQLite nhảy tới cuối khối okx và đọc đúng một mục. Vì index chứa luôn buy_rate và sell_rate, query không cần đụng tới bảng. Loại index này gọi là covering index. Plan trên D1 production:

SEARCH p2p_usdt_vnd_rates USING COVERING INDEX idx_rates_exchange_timestamp (exchange=?)

Bản thân query không cần sửa gì.

Kết quả

getLastRate() mỗi lần gọi Rows read Thời gian
Trước (quét toàn bảng) 681.656 680 ms
Sau (covering index) 1 0,38 ms

Chi phí không còn phụ thuộc vào kích thước bảng: luôn là 1 dòng, dù bảng có 600 nghìn hay 10 triệu dòng.

Vậy có phải mỗi query cần một index? Cái giá phải trả

Không. Số index trên bảng này không đổi: thêm một, bỏ một.

Index Trước Sau Được dùng bởi
idx_timestamp ✓ ✓ query trên mọi sàn: worker-detect-signals ("4 dòng mới nhất"), worker-daily-report ("các dòng quanh giờ này hôm qua")
idx_timestamp_range ✓ ✗ không dùng (trùng với idx_timestamp)
idx_rates_exchange_timestamp ✗ ✓ mọi query lọc theo một sàn

Index nhiều cột phục vụ được mọi query dùng các cột ngoài cùng bên trái của nó. Nên một index (exchange, timestamp, ...) dùng được cho nhiều dạng query:

Dạng query Dùng được index?
WHERE exchange = ? ✓
WHERE exchange = ? ORDER BY timestamp DESC LIMIT 1 ✓ (cả 7 bản getLastRate())
WHERE exchange = ? AND timestamp BETWEEN ? AND ? ✓ (lịch sử thô cho biểu đồ)
WHERE timestamp BETWEEN ? AND ? (không có exchange) ✗, nên vẫn giữ idx_timestamp

Nguyên tắc: tạo index cho một kiểu truy cập (access pattern), không phải cho từng query. Trước khi thêm index, kiểm tra xem đã có index nào bắt đầu bằng các cột bạn lọc chưa. Chỉ cần index mới khi có một kiểu lọc mà chưa index nào bắt đầu bằng nó.

Index vẫn có cái giá của nó, và covering index có thêm vài điểm riêng:

  1. Dung lượng. Index là bản sao thứ hai của các cột trong nó. Covering index sao chép gần như cả dòng. Mình giả lập 600.000 dòng bằng SQLite trên máy:

    Cấu trúc Dung lượng
    Bảng p2p_usdt_vnd_rates 23,3 MB
    idx_timestamp 16,0 MB
    idx_rates_exchange_timestamp (covering) 23,2 MB
    Chỉ (exchange, timestamp), để so sánh 19,7 MB

    Covering index nặng gần bằng cả bảng. Nhưng phần chênh để biến nó thành covering thì nhỏ: chỉ hơn bản (exchange, timestamp) thường 3,5 MB. Cả database mới 83,5 MB, còn rất xa mức 5 GB miễn phí.

  2. Chi phí ghi. D1 tính thêm một row written cho mỗi index ở mỗi lần insert. Mỗi mẫu giờ tốn 1 dòng bảng + 2 dòng index = 3 rows written, y như trước vì số index không đổi. Tổng khoảng 5.200 rows written mỗi ngày, thấp hơn nhiều so với 100.000/ngày của gói Free.

  3. Tạo index là một lần ghi hàng loạt. CREATE INDEX trên bảng đã có dữ liệu sẽ đọc mọi dòng và ghi một dòng index cho mỗi dòng: khoảng 600.000 rows written. Gấp 6 lần hạn mức ghi trong ngày của gói Free. Nếu dùng gói Free, hãy tạo index khi bảng còn nhỏ, hoặc chấp nhận hết quota ngày hôm đó. Chạy đúng một lần qua migration, đừng bao giờ chạy trong code ứng dụng.

  4. "Covering" rất dễ mất. Index chỉ covering cho những query SELECT các cột nằm trong nó. Nếu sau này ai đó viết SELECT * hoặc thêm một cột, index vẫn được dùng nhưng SQLite phải đọc thêm dòng trong bảng cho mỗi kết quả. Sau khi sửa query, kiểm tra chữ COVERING trong plan.

  5. Thứ tự cột quan trọng. (exchange, timestamp) không giúp được query chỉ lọc theo timestamp. Vì vậy idx_timestamp vẫn được giữ cho các query trên mọi sàn.

  6. Càng nhiều index, planner càng dễ chọn sai. Khi có các index chồng chéo nhau, planner có thể chọn index bạn không ngờ tới. Trước khi sửa, bảng có tới hai index trên timestamp mà SQLite vẫn chọn quét toàn bảng. Kiểm tra EXPLAIN QUERY PLAN sau mỗi lần đổi schema.


3. Vấn đề 2: cột id thừa trong ORDER BY làm rows read tăng gấp đôi

Bối cảnh

Có hai query sắp xếp bằng ORDER BY timestamp DESC, id DESC: getLastRate() của Binance và nhánh đọc dữ liệu thô của endpoint lịch sử (Vấn đề 3). Cột id được thêm vào để phân định các dòng trùng timestamp. Nhưng mỗi sàn chỉ ghi đúng một dòng cho mỗi lần cron, nên trong cùng một sàn không bao giờ có hai dòng trùng timestamp. Cột phụ này chưa bao giờ có tác dụng.

Vì sao tốn

Index mới được sắp theo (exchange, timestamp). Sắp thêm theo id không khớp thứ tự đó, nên SQLite phải thêm một bước sort. Plan trên D1 production:

SEARCH p2p_usdt_vnd_rates USING COVERING INDEX idx_rates_exchange_timestamp (exchange=?)
USE TEMP B-TREE FOR LAST TERM OF ORDER BY

Để sắp theo id giữa các dòng cùng timestamp, SQLite phải đọc vượt qua dòng nó cần. Rows read đo được tăng gấp đôi: 3 thay vì 1 cho query giá gần nhất, 577 thay vì 289 cho lịch sử 1 ngày.

Cách sửa

  SELECT buy_rate, sell_rate, timestamp
  FROM p2p_usdt_vnd_rates
  WHERE exchange = ?
- ORDER BY timestamp DESC, id DESC
+ ORDER BY timestamp DESC
  LIMIT 1

Kết quả

Query Trước Sau
Giá gần nhất (Binance) 3 1
Lịch sử 1 ngày, một sàn 577 289

Bài học: ORDER BY phải khớp chính xác với thứ tự của index. Nếu không, SQLite sẽ thêm bước sort và đọc nhiều dòng hơn bạn nghĩ.


4. Vấn đề 3: endpoint lịch sử đọc cả khoảng thời gian hai lần

Bối cảnh: biểu đồ tải dữ liệu như thế nào

Biểu đồ có các khoảng từ 1H tới 1Y. Khi người dùng chọn một khoảng, frontend gửi một request cho mỗi sàn đang chọn:

// frontend-cf/app.js (trước)
const promises = uniqueExchanges.map(async (ex) => {
    const url = `${API_URL}?exchange=${ex}&start=${state.start}&end=${state.end}&limit=50000`;
    const res = await fetch(url);
    // ...
});

Khoảng 1 năm của một sàn có khoảng 100.000 dòng thô, quá nhiều điểm để vẽ. Vì vậy backend giới hạn response ở 1.000 điểm và downsample trên server qua hai bước:

// backend-cf/src/index.js (trước)
async function getHistoricalRates(db, exchange, start, end, limit) {
    const MAX_ALLOWED = 1000;
    const targetLimit = Math.min(limit || 200, MAX_ALLOWED);
    // ... dựng WHERE exchange = ? AND timestamp >= ? AND timestamp <= ?

    // Bước 1: đếm mọi dòng trong khoảng thời gian
    const countQuery = `SELECT COUNT(*) as count FROM p2p_usdt_vnd_rates ${whereClause}`;
    const totalCount = (await db.prepare(countQuery).bind(...params).first())?.count || 0;

    const step = Math.ceil(totalCount / targetLimit);
    if (totalCount > targetLimit && step > 1) {
        // Bước 2: đánh số mọi dòng trong khoảng, giữ lại mỗi dòng thứ N
        const sampledQuery = `
            SELECT timestamp, exchange, buy_rate, sell_rate FROM (
                SELECT timestamp, exchange, buy_rate, sell_rate,
                    ROW_NUMBER() OVER (ORDER BY timestamp DESC, id DESC) as row_num
                FROM p2p_usdt_vnd_rates ${whereClause}
            ) WHERE (row_num - 1) % ? = 0 LIMIT ?`;
        // ...
    }
}

Vì sao tốn

Không có index bắt đầu bằng exchange, nên cả hai bước đều đi qua index timestamp trên toàn bộ khoảng thời gian của tất cả các sàn, đọc từng dòng trong bảng, rồi mới lọc theo sàn:

SEARCH p2p_usdt_vnd_rates USING INDEX idx_timestamp_range (timestamp>? AND timestamp<?)

Mình chạy lại bước đếm trên production cho 1 năm dữ liệu OKX:

476.001 rows read và 610 ms chỉ để tính ra một con số (99.686).

Bước ROW_NUMBER() lại đọc cả khoảng đó thêm một lần. Vậy một request 1Y tốn khoảng 950.000 rows read, và với 6 sàn, một lần tải biểu đồ tốn khoảng 5,7 triệu (ước tính: mình chỉ đo bước đầu). Một lượt xem biểu đồ 1Y đã vượt cả hạn mức một ngày của gói Free.

Covering index ở Vấn đề 1 giúp hai bước này rẻ hơn, vì khi đó chúng chỉ đọc các dòng của OKX. Nhưng chúng vẫn đọc mọi dòng OKX trong khoảng, khoảng 100.000 dòng mỗi năm, nên biểu đồ 1Y vẫn tốn thêm mỗi ngày. Khoảng thời gian dài cần dữ liệu đã được gộp sẵn.

Cách sửa, phần 1: bảng rollup theo giờ/ngày

CREATE TABLE IF NOT EXISTS p2p_usdt_vnd_rates_rollup (
    period TEXT NOT NULL,          -- 'hour' hoặc 'day'
    exchange TEXT NOT NULL,
    bucket TEXT NOT NULL,          -- thời điểm bắt đầu bucket theo GMT+7, vd. '2026-10-02 16:00:00'
    buy_sum REAL NOT NULL DEFAULT 0,
    buy_count INTEGER NOT NULL DEFAULT 0,
    sell_sum REAL NOT NULL DEFAULT 0,
    sell_count INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (period, exchange, bucket)
) WITHOUT ROWID;

Các lựa chọn thiết kế:

  • Lưu tổng và số lượng, không lưu trung bình. Mẫu mới chỉ cần sum + x, count + 1, nên không bao giờ phải tính lại bucket từ dữ liệu thô. Giá trị trung bình được tính lúc đọc.
  • WITHOUT ROWID với primary key (period, exchange, bucket). Bảng được lưu dưới dạng B-tree sắp theo đúng khóa mà mình truy vấn. Bản thân bảng chính là index, nên không cần thêm index phụ và không tốn thêm lần ghi index nào.
  • Nhỏ. Mỗi sàn một dòng cho mỗi giờ cộng một dòng cho mỗi ngày: hiện khoảng 50.000 dòng, so với khoảng 600.000 dòng thô.

Cách sửa, phần 2: cập nhật rollup trong cùng batch với lệnh insert

Collector trước đây:

// backend-cf/src/index.js (trước)
for (const [exchange, rates] of Object.entries(exchanges)) {
    try {
        await db.prepare(`
            INSERT INTO p2p_usdt_vnd_rates (timestamp, exchange, buy_rate, sell_rate)
            VALUES (?, ?, ?, ?)
        `).bind(now, exchange, rates.buy, rates.sell).run();
    } catch (error) {
        console.error(`Error storing ${exchange} rates:`, error);
    }
}

Collector sau khi sửa:

// backend-cf/src/index.js (sau)
const hourBucket = now.substring(0, 13) + ':00:00';
const dayBucket = now.substring(0, 10) + ' 00:00:00';

const insertRate = db.prepare(`
    INSERT INTO p2p_usdt_vnd_rates (timestamp, exchange, buy_rate, sell_rate)
    VALUES (?, ?, ?, ?)
`);
// Cộng mẫu vào bucket giờ/ngày tại chỗ, để rollup không bao giờ phải đọc lại dữ liệu thô
const upsertRollup = db.prepare(`
    INSERT INTO p2p_usdt_vnd_rates_rollup (period, exchange, bucket, buy_sum, buy_count, sell_sum, sell_count)
    VALUES (?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT (period, exchange, bucket) DO UPDATE SET
        buy_sum = buy_sum + excluded.buy_sum, buy_count = buy_count + excluded.buy_count,
        sell_sum = sell_sum + excluded.sell_sum, sell_count = sell_count + excluded.sell_count
`);

// Mỗi sàn một batch (transaction): dữ liệu thô và rollup luôn khớp nhau,
// và lỗi ở một sàn không chặn các sàn còn lại.
for (const [exchange, rates] of Object.entries(exchanges)) {
    try {
        const buy = Number.isFinite(rates?.buy) ? rates.buy : null;
        const sell = Number.isFinite(rates?.sell) ? rates.sell : null;
        const rollup = [buy ?? 0, buy === null ? 0 : 1, sell ?? 0, sell === null ? 0 : 1];

        await db.batch([
            insertRate.bind(now, exchange, buy, sell),
            upsertRollup.bind('hour', exchange, hourBucket, ...rollup),
            upsertRollup.bind('day', exchange, dayBucket, ...rollup)
        ]);
    } catch (error) {
        console.error(`Error storing ${exchange} rates:`, error);
    }
}

Cron đã có sẵn giá trị trong bộ nhớ, nên cập nhật bucket không cần đọc lại dòng thô nào. db.batch() chạy ba lệnh trong một transaction: hoặc ghi được cả dòng thô lẫn hai bucket, hoặc không ghi gì.

Migration backfill rollup một lần từ dữ liệu cũ: bucket giờ tạo từ dữ liệu thô, bucket ngày tạo từ bucket giờ. Có một lỗi phát sinh: một dòng cũ import từ MySQL có timestamp là NULL, tạo ra bucket NULL và vi phạm ràng buộc NOT NULL. Cách sửa là bỏ qua các dòng có timestamp không parse được:

WHERE exchange IS NOT NULL AND strftime('%Y-%m-%d %H:00:00', timestamp) IS NOT NULL

Cách sửa, phần 3: chọn độ phân giải theo khoảng thời gian

Endpoint không còn đếm hay đánh số dòng nữa. Nó xem khoảng thời gian được yêu cầu và đọc từ nguồn phù hợp bằng một query duy nhất:

Khoảng thời gian Nguồn dữ liệu Số điểm mỗi sàn
≤ 2 ngày dữ liệu thô 5 phút ≤ 576
≤ 35 ngày bucket theo giờ ≤ 840
> 35 ngày bucket theo ngày 1 điểm/ngày
// backend-cf/src/index.js (sau)
const RAW_MAX_SPAN_MS = 2 * DAY_MS;
const HOURLY_MAX_SPAN_MS = 35 * DAY_MS;

async function getHistoricalRates(db, exchange, start, end, limit) {
    const targetLimit = Math.max(1, Math.min(limit || 200, HISTORY_MAX_ROWS));
    const span = (parseGMT7(end) ?? Date.now()) - (parseGMT7(start) ?? 0);
    // ...
    if (span <= RAW_MAX_SPAN_MS) {
        // WHERE exchange = ? AND timestamp BETWEEN ... -> seek trên covering index
        query = `SELECT timestamp, exchange, buy_rate, sell_rate
                 FROM p2p_usdt_vnd_rates${whereClause}
                 ORDER BY timestamp DESC LIMIT ?`;
    } else {
        conditions.push('period = ?');
        params.push(span <= HOURLY_MAX_SPAN_MS ? 'hour' : 'day');
        // exchange = ? (hoặc exchange IN (...tất cả các sàn) để vẫn dùng được primary key)
        // bucket >= ? AND bucket <= ?
        query = `
            SELECT bucket AS timestamp, exchange,
                ROUND(buy_sum / NULLIF(buy_count, 0), 2) AS buy_rate,
                ROUND(sell_sum / NULLIF(sell_count, 0), 2) AS sell_rate
            FROM p2p_usdt_vnd_rates_rollup
            WHERE ${conditions.join(' AND ')}
            ORDER BY bucket DESC LIMIT ?`;
    }
    const rows = (await db.prepare(query).bind(...params, targetLimit).all()).results || [];
    return { data: rows.reverse() };
}

Giờ nhánh nào cũng chỉ là một lần seek:

SEARCH p2p_usdt_vnd_rates_rollup USING PRIMARY KEY (period=? AND exchange=? AND bucket>? AND bucket<?)

Khi request không lọc theo exchange, query dùng exchange IN ('binance', 'bybit', ...) thay vì bỏ trống điều kiện exchange. Primary key là (period, exchange, bucket). Nếu không có giá trị cho exchange, cột ở giữa, SQLite không dùng được khóa cho điều kiện khoảng của bucket và sẽ phải đọc mọi bucket của period đó.

Kết quả (đo trên production, sàn OKX)

Request Trước Sau
1 ngày 577 dòng (còn cột id phụ) 289 dòng
1 năm 476.001 dòng / 610 ms chỉ riêng bước đếm (~950k cho cả hai bước) 364 dòng / 1,7 ms

Một lần tải biểu đồ 1Y cho 6 sàn giảm từ khoảng 5,7 triệu rows read xuống khoảng 2.200.

Cái giá của rollup:

  • Ghi nhiều hơn. Hai lệnh upsert cho mỗi mẫu thêm khoảng 3.500 rows written mỗi ngày (288 × 6 × 2). Rất nhỏ so với lượng đọc đã cắt được.
  • Giá trung bình thay vì điểm thô. Khoảng trên 2 ngày hiển thị trung bình theo giờ hoặc theo ngày. Một đột biến chỉ kéo dài 5 phút sẽ biến mất trong trung bình ngày. Với biểu đồ xu hướng thì đó là điều mình muốn, và đường giá không còn phụ thuộc vào việc mẫu thô nào tình cờ rơi vào vị trí thứ N.
  • Thêm một thứ phải giữ đồng bộ. Nếu dữ liệu thô bị sửa hoặc xóa bằng tay, bucket tương ứng cũng phải sửa theo. Có thể chạy lại phần backfill trong migration cho việc này, vì nó tính lại mọi bucket từ đầu.

5. Vấn đề 4: đường tỷ giá ngân hàng đọc cả bảng VCB

Bối cảnh

Biểu đồ còn vẽ tỷ giá USD/VND của Vietcombank (VCB). Dữ liệu này nằm ở bảng riêng vcb_usd_vnd_rates. Cron hỏi VCB mỗi 30 phút nhưng chỉ lưu một dòng khi tỷ giá thay đổi, cộng một dòng "heartbeat" mỗi 12 giờ. Vì vậy bảng tăng rất chậm: 866 dòng kể từ tháng 4/2026.

Đường tỷ giá được vẽ dạng bậc thang: mỗi giá trị giữ nguyên tới lần thay đổi tiếp theo. Để vẽ đúng bậc đầu tiên, biểu đồ cần lần thay đổi cuối cùng trước điểm bắt đầu của khung. Code cũ lấy nó bằng cách tải mọi thứ tới ngày kết thúc:

// frontend-cf/app.js (trước)
const res = await fetch(`${apiUrl}?end=${state.end}&limit=5000`);

Vì sao tốn

Không có start, nên mỗi lần tải trang đọc 5.000 dòng mới nhất. Hiện tại tức là cả bảng, 866 dòng, kể cả khi biểu đồ chỉ hiển thị một giờ. Con số này nhỏ so với Vấn đề 1 và 3, nhưng nó tăng theo mỗi lần tỷ giá đổi và chạy ở mọi lượt xem trang.

Cách sửa

Frontend giờ gửi thêm start. Backend trả về các dòng trong khung cộng với một dòng "neo": dòng cuối cùng trước start. Cả hai query được gửi trong một lần db.batch():

// frontend-cf/app.js (sau)
// Có start, API trả về dữ liệu trong khung cộng với lần cập nhật cuối trước đó
const res = await fetch(`${apiUrl}?start=${state.start}&end=${state.end}&limit=5000`);
// backend-cf/src/index.js (sau)
const statements = [db.prepare(query).bind(...params)];
if (start) {
    statements.push(db.prepare(
        'SELECT timestamp, buy_rate, transfer_rate, sell_rate FROM vcb_usd_vnd_rates WHERE timestamp < ? ORDER BY timestamp DESC LIMIT 1'
    ).bind(start));
}
const [result, anchor] = await db.batch(statements);
return { data: [...(anchor?.results || []), ...(result.results || []).reverse()] };

Kết quả

Rows read giảm từ cả bảng (866 dòng hiện tại, tối đa 5.000) xuống còn số dòng trong khung cộng 1. Với biểu đồ 1 ngày thường chỉ vài dòng.

Bảng VCB cũng có cặp index trùng nhau giống bảng chính trước đây (idx_vcb_timestamp và idx_vcb_timestamp_range). Bảng chưa tới 1.000 dòng nên gần như không tốn gì, mình tạm để nguyên.


6. Tổng kết kết quả

Query Rows read trước Rows read sau Nguồn số liệu
Giá gần nhất, mỗi sàn mỗi lần gọi 681.656 1 đo thực tế
Giá gần nhất, riêng cron, mỗi ngày ~1,2 tỷ ~1.700 (288 × 6) tính toán
Lịch sử 1 ngày, một sàn 577 (sau khi thêm index) 289 đo thực tế
Lịch sử 1 năm, một sàn ~950.000 (476.001 cho bước đếm) 364 bước đếm đo thực tế, tổng là ước tính
Biểu đồ 1 năm, 6 sàn ~5,7 triệu ~2.200 ước tính
Đường tỷ giá VCB cả bảng (866) số dòng trong khung + 1 đo thực tế

Các hiệu ứng khác:

  • Rows read không còn tăng theo kích thước bảng. Các query chạy nhiều nhất giờ đều là index seek, nên chi phí giữ nguyên khi dữ liệu tích lũy.
  • Chi phí biểu đồ gần như bằng nhau ở mọi khoảng. 1D, 1W, 1M và 1Y đều chỉ đọc vài trăm dòng mỗi sàn.
  • Phản hồi nhanh hơn. Query giá gần nhất giảm từ 680 ms xuống dưới 1 ms, nên cả cron lẫn endpoint giá hiện tại đều xong sớm hơn.

Trạng thái index sau khi tối ưu:

Bảng Index
p2p_usdt_vnd_rates idx_timestamp, idx_rates_exchange_timestamp (2 index, bằng số lượng trước đây)
p2p_usdt_vnd_rates_rollup không có index nào ngoài primary key (WITHOUT ROWID)

7. Bài học rút ra

  1. Chạy EXPLAIN QUERY PLAN cho mọi query chạy thường xuyên. Thấy SCAN <table> hay USE TEMP B-TREE trên bảng lớn nghĩa là bạn đang trả tiền cho từng dòng, bất kể LIMIT là bao nhiêu.

  2. Thiết kế index theo kiểu truy cập. Đặt cột so sánh bằng (exchange) lên đầu, rồi tới cột khoảng hoặc cột sắp xếp (timestamp). Một index đúng thứ tự có thể phục vụ nhiều query.

  3. Biến index thành covering index khi chi phí thấp. Ở đây nó chỉ tốn thêm 3,5 MB so với index thường. Sau khi sửa query, kiểm tra chữ COVERING trong plan.

  4. Xóa index không dùng tới. Index nào cũng tốn một lần ghi ở mỗi lệnh insert, cộng thêm dung lượng.

  5. ORDER BY phải khớp chính xác với index. Một cột phụ không cần thiết có thể làm rows read tăng gấp đôi.

  6. Tránh COUNT(*), window function và sort lớn trên đường xử lý request. Chúng đọc mọi dòng khớp điều kiện dù bạn chỉ trả về vài dòng.

  7. Gộp dữ liệu ngay lúc ghi. Nếu cron đã có sẵn giá trị, cộng vào bucket chỉ tốn một lần ghi thay vì rất nhiều lần đọc về sau.

  8. Đo trên production. Mọi kết quả trả về từ D1 đều có meta.rows_read. Để xem query nào tốn nhất:

    npx wrangler d1 insights usdt-vnd-rates-db --sort-by reads
    

Cái giá phải trả

Không quá đắt nhưng cũng đủ để nhớ, - 1 bữa nhậu

5$ cho Paid Worker plan + ~12$ D1 Row read usage

image.png


Quảng cáo

Đây là các product nhỏ nhỏ mình đang làm chơi trong thời gian rảnh:

Nếu như bạn đang gặp khó khăn trong vấn đề chuyên môn, cần người hỗ trợ về hệ thống, DevOps tools hay cần định hướng trong công việc thì mình tự tin có thể hỗ trợ được bạn. Liên hệ với mình để trao đổi thêm nhé https://hoangviet.io.vn/, mình rất vui khi được trao đổi và cộng tác với bạn. Happy Coding! 👨‍💻


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí