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-vndgọ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:
- 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).
- 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:
-
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_rates23,3 MB idx_timestamp16,0 MB idx_rates_exchange_timestamp(covering)23,2 MB Chỉ (exchange, timestamp), để so sánh19,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í. -
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.
-
Tạo index là một lần ghi hàng loạt.
CREATE INDEXtrê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. -
"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ữCOVERINGtrong plan. -
Thứ tự cột quan trọng.
(exchange, timestamp)không giúp được query chỉ lọc theotimestamp. Vì vậyidx_timestampvẫn được giữ cho các query trên mọi sàn. -
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
timestampmà SQLite vẫn chọn quét toàn bảng. Kiểm traEXPLAIN QUERY PLANsau 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 ROWIDvớ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
-
Chạy
EXPLAIN QUERY PLANcho mọi query chạy thường xuyên. ThấySCAN <table>hayUSE TEMP B-TREEtrên bảng lớn nghĩa là bạn đang trả tiền cho từng dòng, bất kểLIMITlà bao nhiêu. -
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. -
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ữ
COVERINGtrong plan. -
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.
-
ORDER BYphả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. -
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. -
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.
-
Đ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

Quảng cáo
Đây là các product nhỏ nhỏ mình đang làm chơi trong thời gian rảnh:
- Công cụ theo dõi giá USDT/VND P2P trên các sàn quốc tế: https://usdt.hoangviet.io.vn/
- Công cụ theo dõi giá vàng tại Việt Nam, so sánh chênh lệch với thế giới: https://gold.hoangviet.io.vn/
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