Giao diện
Tách đọc / ghi
Tách đọc ghi là gửi mọi lệnh ghi vào primary, còn phần lớn lệnh đọc thì gửi sang các replica. Phần lớn ứng dụng web đọc nhiều hơn ghi rất nhiều, nên thêm replica là cách rẻ để tăng sức đọc mà không đụng vào primary.
Cái giá là replication lag: replica luôn chậm hơn primary một chút, và có lúc chậm hàng phút. Đọc từ replica nghĩa là chấp nhận có lúc thấy dữ liệu cũ. Trang này nói lúc nào thấy dữ liệu cũ thì gây lỗi, và cách chặn từng loại lỗi trong code Node.js. Phần khái niệm chung (sync, async, ba kiểu đọc sai) nằm ở Replication; cách MySQL replicate nằm ở MySQL chương 9.
Các số đo trong trang chạy thật ngày 2026-10-19 trên MySQL 8.0.46 với một replica GTID, Docker trên laptop. Script ở cuối trang.
Khi nào nên tách
- Primary bận vì đọc, không phải vì ghi. Nếu primary bận vì ghi thì replica không giúp được, vì mỗi replica cũng phải áp dụng lại toàn bộ lệnh ghi.
- Báo cáo, export, dashboard nội bộ chạy query nặng: đẩy sang một replica riêng để không ảnh hưởng người dùng.
- Người dùng ở xa: đặt replica gần họ để đọc nhanh hơn.
Trước khi tách, thử các cách rẻ hơn: thêm index đúng (MySQL chương 4), sửa query chậm (MySQL chương 5), thêm cache (Cache). Tài liệu ProxySQL cũng khuyên không chuyển mọi SELECT sang replica một lượt. Nên để mọi thứ chạy trên primary trước, xem query nào đắt nhất, rồi chỉ chuyển những query đó, sau khi chắc chúng đọc dữ liệu hơi cũ cũng không sao (ProxySQL: Read/Write Split).
Định tuyến ở đâu
| Chỗ | Cách làm | Ưu và nhược |
|---|---|---|
| Code ứng dụng | Hai connection pool, primary và replica; code tự chọn pool cho từng query | Kiểm soát tốt nhất: biết query nào cần dữ liệu mới. Code phải tự lo |
| ORM | Sequelize: cấu hình replication: { read: [...], write: {...} }; SELECT vào pool đọc, xoay vòng giữa các replica (Sequelize: Read Replication). Prisma: extension @prisma/extension-read-replicas; mọi thao tác đọc vào một replica chọn ngẫu nhiên, ghi và $transaction vào primary, $primary() để ép đọc primary (Prisma: Read replicas) | Dễ bật. Nhưng mặc định mọi lệnh đọc đều có thể vào replica, kể cả lệnh đọc ngay sau khi ghi; và chọn replica xoay vòng hay ngẫu nhiên đều gây lỗi đọc lùi ở dưới |
| Proxy | ProxySQL (MySQL), pgpool-II (PostgreSQL): app nối vào proxy, proxy chọn server theo luật | App không phải sửa. Nhưng proxy không biết nghiệp vụ, nên dễ đưa nhầm lệnh đọc cần dữ liệu mới sang replica |
Cách nào thì cũng phải có cơ chế để ép một lệnh đọc vào primary khi cần, như các mục dưới.
Đọc replica ngay sau khi ghi: đo được gì
Phép đo: ghi một dòng vào primary rồi đọc ngay dòng đó trên replica, 300 lượt. Nếu chưa thấy thì hỏi lại mỗi mili giây để đo phải chờ bao lâu. Đo cả lúc rảnh, và lúc có một job liên tục cập nhật từng lô 5.000 dòng (kiểu job backfill chạy không điều tốc, Xử lý theo lô).
| Lúc rảnh (3 lượt) | Lúc rảnh, lượt đầu tiên | Lúc có tải ghi nặng (1 lượt) | |
|---|---|---|---|
| Đọc replica ngay sau khi ghi: thấy dữ liệu cũ | 9 – 9,3% số lần | 100% | 100% |
| Những lần thấy cũ phải chờ bao lâu mới thấy | trung vị 2,4 ms, lâu nhất 4 – 21 ms | trung vị 2,3 ms, lâu nhất 64 ms | trung vị 2,4 s; 24/30 lượt chờ quá 5 s |
| Chờ GTID rồi mới đọc replica: thấy dữ liệu cũ | 0% | 0% | chờ quá 5 s ở cả 30/30 lượt |
| Thời gian phải chờ GTID | trung vị 0,6 – 0,7 ms, lâu nhất 3,4 ms | trung vị 1,2 ms, lâu nhất 15 ms | — |
| Pool xoay vòng primary rồi replica: lần đọc sau mất dòng mà lần đọc trước vừa thấy | 2,7 – 4,7% | 94% | 100% |
Lượt đầu tiên chạy ngay sau khi replica vừa áp dụng xong một đống việc tồn hơn hai phút, và lúc đó replica còn ghi binlog riêng. Ba lượt sau chạy trên replica rảnh hẳn, không ghi binlog. Lúc có tải, replica tụt dần tới 95 giây ở cuối phép đo.
Rút ra:
- Kể cả lúc rảnh, cứ khoảng 10 lần đọc ngay sau khi ghi thì có 1 lần thấy dữ liệu cũ. Lag chỉ vài mili giây, nhưng lệnh đọc đến replica còn nhanh hơn thế. Đây là lý do “sau khi đặt hàng, trang danh sách không có đơn” xảy ra cả khi hệ thống không có gì bất thường.
- Tỉ lệ đó thay đổi rất mạnh theo trạng thái replica: từ 9% lên 100% chỉ vì replica đang bận. Không thể thiết kế dựa trên con số “thường thì lag nhỏ”.
- Chờ GTID chặn được hoàn toàn chuyện đọc cũ lúc bình thường, chỉ tốn khoảng 1 ms. Nhưng khi replica tụt xa, mọi lần chờ đều hết thời gian, nên phải có đường lui là đọc primary.
Ba lỗi khi đọc replica, và cách chặn
1. Không thấy thứ mình vừa ghi
User đặt hàng xong được chuyển sang trang “Đơn hàng của tôi”, trang này đọc từ replica và chưa có đơn mới. User tưởng lỗi, bấm đặt lại.
| Cách chặn | Làm thế nào | Hạn chế |
|---|---|---|
| Đọc primary một lúc sau khi user ghi | Sau mỗi lệnh ghi, đánh dấu user đó (key trong Redis có TTL vài giây, hoặc cookie); các lệnh đọc của user trong lúc đó vào primary | Khi lag vượt khoảng thời gian đã chọn thì vẫn đọc cũ; phép đo ở trên cho thấy lag có lúc lên hàng chục giây |
| Chờ replica tới GTID của lệnh ghi | Sau khi ghi, lấy GTID set của primary và lưu theo user; trước khi đọc replica, WAIT_FOR_EXECUTED_GTID_SET(gtid, timeout); hết thời gian chờ (trả về 1) thì đọc primary (MySQL: GTID Functions) | Chính xác; lúc bình thường chỉ chờ khoảng 1 ms. Lúc replica tụt xa thì lần nào cũng hết thời gian chờ |
| Trang ngay sau thao tác ghi luôn đọc primary | Trang xác nhận đơn hàng, trang hồ sơ vừa sửa đọc thẳng primary | Đơn giản nhất, đủ cho phần lớn trường hợp |
Lưu ý về cách chờ GTID: replica áp dụng transaction theo đúng thứ tự commit trên primary. Vì vậy, muốn thấy lệnh ghi của mình thì phải chờ replica áp dụng xong mọi transaction đứng trước nó, kể cả tải ghi của người khác. Đó là lý do lúc có tải nặng, cả 30 lượt chờ đều hết thời gian.
Trên PostgreSQL, ý tưởng tương tự là so pg_current_wal_lsn() trên primary với pg_last_wal_replay_lsn() trên replica, rồi chờ tới khi replica đi qua vị trí của lệnh ghi.
Kết hợp thêm idempotency key cho thao tác đặt hàng, để lỡ user bấm lại thì cũng không tạo đơn thứ hai (Chống trùng & race condition).
2. Đọc lùi về quá khứ
User đăng comment rồi tải lại trang hai lần. Lần đầu pool đưa vào một server đã có comment, lần sau đưa vào replica chậm hơn, và comment biến mất. Phép đo ở trên tái hiện được: pool xoay vòng giữa primary và replica làm 3 – 5% lần đọc sau mất dữ liệu mà lần đọc trước vừa thấy, và lúc replica bận thì gần như lần nào cũng vậy.
Cách chặn: mỗi user luôn đọc cùng một replica, ví dụ chọn theo hash(user_id) % số replica. Đọc cùng một replica thì dữ liệu có thể cũ, nhưng chỉ mới dần lên chứ không lùi lại. Đừng để primary nằm trong vòng xoay của pool đọc, và đừng chọn replica ngẫu nhiên cho từng lệnh đọc (đó là mặc định của nhiều ORM, như bảng ở trên).
3. Ra quyết định ghi dựa trên dữ liệu đọc từ replica
Code kiểm tra số dư, kiểm tra tồn kho, kiểm tra email đã dùng chưa, đọc qua connection mặc định mà connection đó trỏ vào replica. Kết quả là quyết định dựa trên dữ liệu cũ: rút tiền vượt số dư, bán quá số hàng.
Quy tắc: mọi lệnh đọc dùng để quyết định một lệnh ghi phải đọc từ primary, trong cùng transaction, kèm SELECT … FOR UPDATE hoặc ghi có điều kiện (Chống trùng & race condition). Đọc từ replica rồi mới ghi vào primary thì không lock được gì cả.
Lúc rảnh, replica thường chậm vài mili giây; lúc có tải ghi nặng có thể vài giây (xem số đo bên dưới trang).
Mọi lần đọc vào replicakhông thấy đơn
Đọc từ replica · Primary không phải đọc gìĐọc lúc 100 ms mà replica chậm 200 ms: danh sách chưa có đơn mới.Đọc primary trong 2 s sau khi user ghithấy đơn mới
Đọc từ primary · Primary chỉ gánh lượt đọc của user vừa ghiVẫn trong khoảng thời gian đọc primary.Chờ replica tới GTID của lệnh ghi (tối đa 1 s, quá thì đọc primary)thấy đơn mới
Đọc từ replica · chờ thêm 100 ms · Primary chỉ gánh khi replica chậm quá ngưỡngChờ thêm 100 ms rồi đọc replica.Mọi lần đọc vào primarythấy đơn mới
Đọc từ primary · Primary gánh toàn bộ lượt đọc: tách đọc ghi không còn tác dụngLuôn đúng.Mô hình đơn giản hoá: lag giữ cố định trong suốt tình huống; thực tế lag thay đổi theo tải.
Các bài toán thực tế
| Tình huống | Chuyện gì xảy ra | Cách sửa |
|---|---|---|
| Đặt hàng xong không thấy đơn, bấm đặt lại | Trang danh sách đọc replica chưa có đơn mới | Đọc primary sau khi ghi hoặc chờ GTID; idempotency key cho API đặt hàng |
| Kiểm tra số dư trên replica rồi trừ tiền | Hai request liên tiếp cùng thấy số dư cũ | Đọc primary trong transaction, UPDATE … WHERE balance >= ? |
| Cache bị nạp lại giá trị cũ | Đổi giá trên primary, xoá cache; request kế tiếp bị miss, đọc replica còn giá cũ rồi ghi giá cũ vào cache, giá sai nằm tới hết TTL | Nạp cache từ primary; hoặc xoá cache lần hai sau một khoảng dài hơn lag (Nhất quán dữ liệu) |
| Service khác nhận event nhưng tìm không thấy dữ liệu | Service A ghi rồi publish event; service B gọi API của A, API đó đọc replica nên trả 404 | Đưa đủ dữ liệu vào event; hoặc API đó đọc primary; hoặc retry có backoff khi gặp 404 |
| Comment hiện rồi biến mất khi tải lại | Pool xoay vòng giữa các replica chậm khác nhau | Mỗi user một replica cố định |
| Cả trang chậm cập nhật vào ban đêm | Job xoá hoặc backfill hàng triệu dòng làm replica tụt hàng phút | Chia lô và điều tốc theo lag (Xử lý theo lô) |
Theo dõi lag và rút replica khỏi pool
- Đo lag bằng heartbeat: một tiến trình ghi thời gian hiện tại vào một bảng trên primary mỗi 100 ms; đọc bảng đó trên replica rồi lấy hiệu.
Seconds_Behind_SourcecủaSHOW REPLICA STATUSchỉ chính xác tới giây và có lúc raNULL. Phép đo trên trang này dùng heartbeat. - Rút replica khỏi pool đọc khi lag vượt ngưỡng (ví dụ 1 giây), và đưa lại khi đã theo kịp.
- Cẩn thận khi mọi replica cùng chậm. Nếu chuyển hết lệnh đọc về primary thì primary có thể quá tải, chính vì nó vốn đã cần replica để chia tải. Với các trang không quan trọng, chấp nhận đọc dữ liệu cũ hoặc trả từ cache có thể an toàn hơn.
- Nguyên nhân hay làm lag tăng (applier không đủ song song, transaction lớn, bảng không có khoá chính): MySQL chương 9.
Code Node.js: lớp db tách đọc ghi
js
// db.js – mysql2/promise và node-redis
import mysql from 'mysql2/promise'
const primary = mysql.createPool(config.primary)
const replicas = config.replicas.map((c) => ({ pool: mysql.createPool(c), healthy: true }))
// Mỗi user luôn đọc cùng một replica, để dữ liệu không lùi về quá khứ
function replicaFor(userId) {
const live = replicas.filter((r) => r.healthy)
if (!live.length) return null
let h = 0
for (const ch of String(userId)) h = (h * 31 + ch.charCodeAt(0)) >>> 0
return live[h % live.length].pool
}
export async function write(userId, sql, params) {
const [res] = await primary.query(sql, params)
// trong 3 giây sau khi ghi, mọi lệnh đọc của user này vào primary
await redis.set(`recent-write:${userId}`, '1', { PX: 3000 })
return res
}
export async function read(userId, sql, params) {
const recent = await redis.exists(`recent-write:${userId}`)
const pool = recent ? primary : (replicaFor(userId) ?? primary)
const [rows] = await pool.query(sql, params)
return rows
}
// Lệnh đọc để quyết định ghi: luôn trên primary, trong transaction
export async function transaction(fn) {
const conn = await primary.getConnection()
try {
await conn.beginTransaction()
const result = await fn(conn)
await conn.commit()
return result
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
}
// Mỗi giây đo lag bằng heartbeat; replica chậm quá 1 giây thì tạm rút khỏi pool đọc
setInterval(async () => {
for (const r of replicas) {
try {
const [[hb]] = await r.pool.query('SELECT ts FROM heartbeat WHERE id = 1')
r.healthy = Date.now() - hb.ts < 1000
} catch {
r.healthy = false
}
}
}, 1000).unref()Vài điểm cần để ý:
- Đánh dấu “vừa ghi” phải dùng chung giữa các instance. Một
Maptrong bộ nhớ chỉ có tác dụng trong một process; request sau của cùng user có thể vào instance khác. Vì vậy ví dụ dùng Redis; dùng cookie cũng được. - Bảng
heartbeatcần một tiến trình ghiDate.now()vào primary mỗi 100 ms, và đồng hồ các máy phải được đồng bộ (NTP). - Rút một replica khỏi pool làm đổi cách chia user, nên một số user bị chuyển sang replica khác và có thể thấy dữ liệu lùi một lần. Muốn tránh hẳn thì cho user của replica bị rút đọc primary, thay vì chia lại.
- Thay cho khoảng 3 giây cố định, có thể lưu GTID sau khi ghi và chờ GTID trước khi đọc như mục 1, có thời gian chờ ngắn rồi lui về primary.
Checklist
- Đã thử index, sửa query, cache trước khi thêm replica chưa?
- Lệnh đọc nào được phép thấy dữ liệu cũ vài giây, lệnh nào không? Chỉ chuyển nhóm đầu sang replica.
- Trang ngay sau thao tác ghi đọc primary, hoặc chờ GTID?
- Mọi lệnh đọc để quyết định ghi nằm trong transaction trên primary?
- Mỗi user đọc một replica cố định?
- Có đo lag (heartbeat), có rút replica khi chậm, có kế hoạch khi mọi replica cùng chậm?
Ghi nhớ
- Tách đọc ghi chỉ giúp khi primary bận vì đọc; replica không chia được tải ghi.
- Đọc replica ngay sau khi ghi thì có lúc thấy dữ liệu cũ, kể cả lúc rảnh: đo được khoảng 9% số lần, lên 100% khi replica bận.
- Thấy thứ mình vừa ghi: đọc primary một lúc sau khi ghi, hoặc chờ GTID rồi lui về primary khi hết thời gian chờ (lúc bình thường chỉ tốn khoảng 1 ms).
- Không đọc lùi về quá khứ: mỗi user một replica cố định, không xoay vòng, không chọn ngẫu nhiên từng lệnh.
- Lệnh đọc để quyết định ghi luôn ở primary, trong cùng transaction.
- Đo lag bằng heartbeat, rút replica chậm khỏi pool, và tính trước trường hợp mọi replica cùng chậm.
Nguồn tham khảo
Đã đo ngày 2026-10-19 trên MySQL 8.0.46 (source và một replica GTID, cấu hình mặc định), Docker trên laptop, bằng script Node.js mysql2. Phần lúc rảnh chạy 4 lượt, mỗi lượt 300 lần cho mỗi cách. Phần lúc có tải chạy 1 lượt, mỗi cách 30 lần, mỗi lần chờ tối đa 5 giây. Lượt đầu tiên của phần lúc rảnh chạy khi replica vừa áp dụng xong việc tồn và còn ghi binlog riêng; ba lượt sau chạy với replica không ghi binlog riêng (--skip-log-bin).
Lần chạy đầu bị hỏng do script: replica còn tồn việc của lần chạy bị dừng trước đó (chậm 126 giây) nên vẫn giữ bảng cũ, và tra theo id trúng nhầm dòng cũ. Script đã sửa để chờ replica theo kịp trước khi đo, và tra theo cả id lẫn nội dung riêng của từng lần.
Tài liệu đã đối chiếu (ngày 2026-10-19):
| Nguồn | Dùng cho phần |
|---|---|
| MySQL 8.4, Functions Used with GTIDs | WAIT_FOR_EXECUTED_GTID_SET chờ tới khi server áp dụng xong một tập GTID; trả 0 khi xong, 1 khi hết thời gian chờ |
| Sequelize: Read Replication | Cấu hình replication, SELECT vào pool đọc, xoay vòng giữa các replica, useMaster |
| Prisma: Read replicas | Extension read replicas; đọc vào replica chọn ngẫu nhiên; ghi và $transaction vào primary; $primary() |
| ProxySQL: Read/Write Split | Không định tuyến mọi SELECT sang replica; bắt đầu với mọi thứ ở primary rồi chuyển các query đắt đã chọn lọc |
Chưa đối chiếu, từ kiến thức chung:
- Replica áp dụng transaction theo thứ tự commit trên primary (MySQL 8.0 giữ thứ tự commit cả khi áp dụng song song).
- Sequelize đưa lệnh đọc trong transaction vào pool ghi (tài liệu read replication không nói).
- Tên hàm PostgreSQL
pg_current_wal_lsn(),pg_last_wal_replay_lsn()và cách chờ theo LSN (chưa chạy thử). - pgpool-II định tuyến đọc ghi cho PostgreSQL.
Code đã dùng để chạy thử
Script nằm trong thư mục lab/tach-doc-ghi/ của repo. Tự chạy lại bằng npm run lab:up rồi npm run lab -- tach-doc-ghi (chỉ đo lúc rảnh, khoảng 25 giây); thêm FULL=1 để đo cả lúc có tải ghi nặng (khoảng 5 phút) (cách chạy).
read-after-write.mjs: đọc replica ngay sau khi ghi, chờ GTID, pool xoay vòng
js
// Trang database/tach-doc-ghi: đọc lại thứ vừa ghi khi đọc từ replica.
// Ba cách đọc, mỗi cách 300 lượt, đo lúc rảnh và lúc có tải ghi nặng:
// replica ghi vào source, đọc ngay từ replica; nếu chưa thấy thì hỏi lại mỗi 1 ms để đo bao lâu mới thấy
// gtid ghi vào source, lấy @@global.gtid_executed, trên replica WAIT_FOR_EXECUTED_GTID_SET rồi mới đọc
// roundrobin pool xoay vòng giữa source và replica: lần đọc 1 vào source, lần đọc 2 vào replica
// Tải ghi nặng: một connection liên tục UPDATE từng lô 5.000 dòng trên bảng 200.000 dòng, không nghỉ.
// Với tải này replica tụt lại ngày càng xa, nên lúc có tải chỉ chạy 30 lượt và mỗi lượt chờ tối đa MAX_WAIT_MS;
// không giới hạn thì mỗi lượt chờ lâu hơn lượt trước và script chạy hàng chục phút.
import mysql from 'mysql2/promise'
import { MYSQL, REPLICA, sleep } from '../common/config.mjs'
const SRC = { ...MYSQL, database: 'rw' }
const REP = { ...REPLICA, database: 'rw' }
const MAX_WAIT_MS = 5000
const now = () => performance.now()
// Chờ replica áp dụng xong mọi thứ đã commit trên source. Cần làm trước khi đo: nếu replica còn đang
// áp dụng tải của lần chạy trước thì nó vẫn giữ bảng cũ, và tra theo id sẽ trúng nhầm dòng cũ.
async function waitReplicaCaughtUp() {
const s = await mysql.createConnection(MYSQL)
const r = await mysql.createConnection(REPLICA)
const [[g]] = await s.query('SELECT @@global.gtid_executed AS g')
const t0 = now()
await r.query('SELECT WAIT_FOR_EXECUTED_GTID_SET(?, 900)', [g.g])
await s.end(); await r.end()
return Math.round(now() - t0)
}
async function setup() {
const waited = await waitReplicaCaughtUp()
if (waited > 1000) console.log(`(đã chờ replica áp dụng nốt việc cũ ${Math.round(waited / 1000)} s)`)
const c = await mysql.createConnection({ ...MYSQL, multipleStatements: true })
await c.query(`CREATE DATABASE IF NOT EXISTS rw; USE rw;
DROP TABLE IF EXISTS items; DROP TABLE IF EXISTS load_t;
CREATE TABLE items (id INT AUTO_INCREMENT PRIMARY KEY, note VARCHAR(50) NOT NULL);
CREATE TABLE load_t (id INT PRIMARY KEY, v INT NOT NULL, pad VARCHAR(100) NOT NULL);
SET SESSION cte_max_recursion_depth = 300000;
INSERT INTO load_t WITH RECURSIVE s AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM s WHERE n < 200000)
SELECT n, 0, REPEAT('x', 80) FROM s;`)
await c.end()
await waitReplicaCaughtUp()
}
const RUN = Date.now().toString(36) // nội dung riêng của lần chạy này, để không bao giờ trúng nhầm dòng cũ
function startLoad() {
let stop = false
const done = (async () => {
const c = await mysql.createConnection(SRC)
while (!stop) for (let from = 1; from <= 200000 && !stop; from += 5000) {
await c.query('UPDATE load_t SET v = v + 1 WHERE id BETWEEN ? AND ?', [from, from + 4999])
}
await c.end()
})()
return async () => { stop = true; await done }
}
const pct = (arr, p) => { if (!arr.length) return 0; const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))] }
const r1 = (x) => Math.round(x * 10) / 10
async function trials(mode, n) {
const src = await mysql.createConnection(SRC)
const rep = await mysql.createConnection(REP)
let stale = 0, capped = 0
const delays = []
for (let i = 0; i < n; i++) {
const note = `${RUN}-${mode}-${i}`
const [res] = await src.query('INSERT INTO items (note) VALUES (?)', [note])
const id = res.insertId
const find = (conn) => conn.query('SELECT id FROM items WHERE id = ? AND note = ?', [id, note]).then(([rows]) => rows)
if (mode === 'replica') {
const t0 = now()
let rows = await find(rep)
if (!rows.length) {
stale++
while (!rows.length && now() - t0 < MAX_WAIT_MS) { await sleep(1); rows = await find(rep) }
if (rows.length) delays.push(now() - t0); else capped++
}
} else if (mode === 'gtid') {
const [[g]] = await src.query('SELECT @@global.gtid_executed AS g')
const t0 = now()
const [[w]] = await rep.query('SELECT WAIT_FOR_EXECUTED_GTID_SET(?, ?) AS w', [g.g, MAX_WAIT_MS / 1000])
if (w.w === 1) capped++ // 1 = hết thời gian chờ
else delays.push(now() - t0)
if (!(await find(rep)).length) stale++
} else {
const a = await find(src) // lượt đọc 1: pool đưa vào source
const b = await find(rep) // lượt đọc 2: pool đưa vào replica
if (a.length && !b.length) stale++ // vừa thấy rồi lại không thấy
}
await sleep(20)
}
await src.end(); await rep.end()
const staleText = `${stale}/${n} lượt (${r1((stale / n) * 100)}%)`
const cap = capped ? `; ${capped} lượt chờ quá ${MAX_WAIT_MS / 1000} s (không tính vào thời gian chờ)` : ''
const d = delays.length ? `trung vị ${r1(pct(delays, 50))} ms, p99 ${r1(pct(delays, 99))} ms, lâu nhất ${r1(Math.max(...delays))} ms` : 'không có lượt nào'
if (mode === 'replica') return `đọc từ replica ngay sau khi ghi: thấy dữ liệu cũ ${staleText}; lượt cũ chờ tới khi thấy: ${d}${cap}`
if (mode === 'gtid') return `chờ GTID rồi đọc replica: thấy dữ liệu cũ ${staleText}; thời gian chờ: ${d}${cap}`
return `pool xoay vòng source → replica: lần đọc sau mất dữ liệu lần đọc trước vừa thấy ${staleText}`
}
async function lagSeconds() {
const r = await mysql.createConnection(REPLICA)
const [[s]] = await r.query('SHOW REPLICA STATUS')
await r.end()
return s.Seconds_Behind_Source
}
// ONLY_IDLE=1 node tach-doc-ghi/read-after-write.mjs : chỉ đo lúc rảnh (nhanh, ghi rất ít)
const PHASES = process.env.ONLY_IDLE ? [['Lúc rảnh', 300]] : [['Lúc rảnh', 300], ['Lúc có tải ghi nặng', 30]]
await setup()
for (const [label, n] of PHASES) {
const stopLoad = n === 300 ? null : startLoad()
if (stopLoad) await sleep(3000)
console.log(`\n=== ${label} (${n} lượt mỗi cách, replica đang chậm ${await lagSeconds()} s)`)
for (const mode of ['replica', 'gtid', 'roundrobin']) console.log(` ${await trials(mode, n)}`)
if (stopLoad) {
console.log(` (replica chậm ${await lagSeconds()} s lúc kết thúc đo)`)
await stopLoad()
}
}
// Để lần chạy sau bắt đầu sạch: chờ replica áp dụng nốt tải vừa tạo
console.log(`\n(chờ replica áp dụng nốt tải ghi: ${Math.round((await waitReplicaCaughtUp()) / 1000)} s)`)
process.exit(0)