Giao diện
Giám sát database
Database chậm thường không báo trước: một index bị xoá, một job chạy lâu, một transaction quên commit, và vài phút sau mọi API đều chậm. Trang này giải thích nên theo dõi chỉ số nào, cách tìm query đáng tối ưu nhất, cảnh báo nào cần gọi người dậy lúc nửa đêm, và nên xem gì trước khi database chậm đột ngột.
Bốn nhóm chỉ số
Cũng như mọi service, database có bốn nhóm chỉ số cơ bản:
| Nhóm | Theo dõi gì | MySQL | PostgreSQL |
|---|---|---|---|
| Tải | Số query mỗi giây, số connection | Questions, Threads_connected / max_connections (mặc định 151) | xact_commit, số dòng trong pg_stat_activity / max_connections (mặc định 100) |
| Độ trễ | p95, p99 của query, theo từng mẫu query | performance_schema | pg_stat_statements |
| Lỗi | Deadlock, chờ lock quá timeout, connection bị từ chối | Innodb_deadlocks, Innodb_row_lock_waits, Connection_errors_* | deadlocks trong pg_stat_database, log |
| Tài nguyên | CPU, I/O, dung lượng đĩa còn trống, bộ nhớ | Hệ điều hành, dung lượng binlog | Hệ điều hành, dung lượng WAL |
Dung lượng đĩa đáng được chú ý riêng: database hết đĩa thì ngừng ghi hẳn, và binlog hay WAL có thể đầy đĩa nhanh hơn dữ liệu nhiều. Nên cảnh báo theo tốc độ đầy, ví dụ "theo đà này sẽ đầy trong 24 giờ", thay vì chỉ theo phần trăm.
Chỉ số riêng của database
Transaction mở lâu
Đây là chỉ số dễ bị bỏ qua nhất mà lại gây nhiều sự cố nhất. Database phải giữ lại các phiên bản cũ của dữ liệu cho tới khi không còn transaction nào cần đọc chúng (MVCC). Một transaction mở lâu, kể cả chỉ đọc hay chỉ đang ngồi chờ (idle in transaction), làm database không dọn được gì kể từ lúc nó bắt đầu:
- MySQL: undo log tích luỹ, đo bằng history list length (
trx_rseg_history_lentrongINNODB_METRICS). Chỉ số này đếm theo transaction: trên lab, nó tăng từ vài chục lên khoảng 5.000 sau 5.000 transaction ghi nhỏ chạy trong lúc có một transaction mở, rồi về 0 ngay sau khi transaction đó commit. Undo log càng dài thì query đọc càng chậm, vì phải lần ngược qua nhiều phiên bản. - PostgreSQL: VACUUM không xoá được dead tuple. Trên lab, sau khi sửa 50.000 dòng, VACUUM vẫn để nguyên cả 50.000 dead tuple vì còn một phiên mở transaction. Bảng và index phình dần (Cấu trúc lưu trữ).
Transaction mở lâu còn làm migration xếp hàng và treo cả bảng (Migration trong team). Theo dõi tuổi của transaction cũ nhất: information_schema.INNODB_TRX (cột trx_started) trên MySQL, pg_stat_activity (cột xact_start) trên PostgreSQL. PostgreSQL còn có idle_in_transaction_session_timeout để tự ngắt phiên ngồi chờ quá lâu.
Buffer pool hit rate
Tỉ lệ lần đọc page tìm thấy sẵn trong RAM (MySQL chương 1). Con số này gần như luôn trên 99%, nên nhìn phần trăm dễ bị đánh lừa: 99,9% của 100 triệu lần đọc vẫn là 100 nghìn lần đọc disk. Nên theo dõi số lần đọc disk mỗi giây (Innodb_buffer_pool_reads trên MySQL, blks_read trên PostgreSQL) và để ý khi nó tăng vọt. Đó là dấu hiệu working set đã lớn hơn RAM, hoặc có query vừa bắt đầu quét một bảng lớn.
Replication lag
Replica chậm bao nhiêu so với primary. Lag tăng làm người dùng đọc phải dữ liệu cũ, và nếu primary sập lúc đó thì failover sẽ mất dữ liệu nhiều hơn. Cách đo đúng và những cái bẫy của Seconds_Behind_Source ở MySQL chương 9.
Số dòng đọc so với số dòng trả về
Query đọc 200.000 dòng để trả về 1 dòng gần như chắc chắn thiếu index. MySQL ghi hai con số này cho từng mẫu query (SUM_ROWS_EXAMINED, SUM_ROWS_SENT). Tỉ lệ cao là dấu hiệu rõ nhất để tìm query cần thêm index.
Tìm query đáng tối ưu nhất
Gom theo mẫu query, không xem từng câu riêng lẻ. SELECT * FROM users WHERE id = 1 và … WHERE id = 2 là cùng một mẫu. MySQL làm việc này trong performance_schema.events_statements_summary_by_digest, bật sẵn từ 8.0. PostgreSQL dùng extension pg_stat_statements, phải thêm vào shared_preload_libraries rồi restart.
Xếp theo tổng thời gian, tức số lần gọi nhân thời gian mỗi lần, chứ không xếp theo query chậm nhất. Trên lab, chạy 10 giây một tải gồm một trang danh sách bị N+1, một báo cáo tìm theo email thiếu index, và các query lấy đơn theo id:
| Mẫu query | Lần gọi | Trung bình | Tổng thời gian | Dòng đọc / dòng trả |
|---|---|---|---|---|
SELECT id, total FROM orders WHERE id = ? | khoảng 26.000 | 0,036 ms | khoảng 930 ms | 1 |
SELECT id, name FROM users WHERE id = ? (N+1) | khoảng 25.000 | 0,035 ms | khoảng 870 ms | 1 |
SELECT COUNT(*) FROM users WHERE email = ? | 20 | 25–28 ms | khoảng 500 ms | 200.000 |
Query chậm nhất (25 ms mỗi lần) không đứng đầu về tổng thời gian. Query N+1, mỗi lần chỉ vài chục µs, lại tốn tổng thời gian nhiều hơn, và cách sửa nó (gom thành một query WHERE id IN (…)) cũng đơn giản hơn (Bẫy của ORM). Mỗi dòng trong bảng cho một hướng xử lý khác nhau: dòng đầu là query đã tối ưu nhưng được gọi rất nhiều (cân nhắc cache), dòng thứ hai là N+1, dòng thứ ba thiếu index.
Slow query log ghi từng câu chạy lâu hơn một ngưỡng. MySQL mặc định tắt (slow_query_log = 0) và ngưỡng mặc định là 10 giây (long_query_time), quá cao với một API. Nên bật và hạ xuống khoảng 0,5–1 giây. PostgreSQL dùng log_min_duration_statement (mặc định tắt). Extension auto_explain còn ghi luôn plan của câu chậm. Slow log có ích để xem đúng câu và tham số gây chậm, còn thống kê theo mẫu cho bức tranh tổng.
Giám sát từ phía ứng dụng
Nhiều vấn đề với database chỉ thấy được từ phía app:
- Thời gian chờ lấy connection từ pool. Query nhanh mà API chậm thì thường là request đang xếp hàng chờ connection (MySQL chương 10).
- Số query mỗi request. Một API bình thường chạy vài query. Nếu con số này tăng theo kích thước dữ liệu (20 đơn thì 21 query, 100 đơn thì 101 query) thì đó là N+1.
- p99 theo từng API, kèm thời gian dành cho database trong mỗi request (qua tracing).
Cảnh báo nào cần gọi người dậy
Chỉ gọi người lúc nửa đêm khi người dùng đang bị ảnh hưởng hoặc sắp bị ảnh hưởng mà không tự hồi phục. Những thứ còn lại thì tạo ticket để xử lý trong giờ làm việc.
| Gọi ngay | Tạo ticket, xử lý trong giờ làm việc |
|---|---|
| Tỉ lệ lỗi hoặc p99 của API vượt mục tiêu (SLO) trong vài phút liên tục | Một mẫu query chậm dần qua các ngày |
| Đĩa sẽ đầy trong vài giờ tới | Hit rate giảm dần |
| Replication bị dừng hẳn, hoặc lag tăng liên tục | Deadlock thỉnh thoảng xuất hiện |
Connection gần chạm max_connections | Transaction mở lâu hơn vài phút (trừ khi kéo dài và history list tăng mạnh) |
| Primary không trả lời, failover không diễn ra | Dung lượng tăng nhanh hơn dự tính |
Cảnh báo nên dựa vào triệu chứng người dùng thấy (lỗi, chậm) hơn là nguyên nhân (CPU 80%). CPU 80% mà người dùng không bị ảnh hưởng thì không cần đánh thức ai.
Khi database chậm đột ngột: xem gì trước
- Có gì vừa thay đổi? Deploy mới, migration, job mới, lưu lượng tăng đột biến. Phần lớn sự cố bắt đầu từ một thay đổi.
- Đang chạy gì?
SHOW PROCESSLISThaysys.processlisttrên MySQL,pg_stat_activitytrên PostgreSQL: có query nào chạy rất lâu, nhiều phiên đang chờ lock không, có transaction nào mở lâu không. - Mẫu query nào tăng tổng thời gian? So thống kê theo mẫu với lúc bình thường. Một mẫu đột nhiên đọc nhiều dòng hơn thường là plan đã đổi, hoặc index bị xoá.
- Có ai đang chờ lock không?
sys.innodb_lock_waitstrên MySQL,pg_locksnối vớipg_stat_activitytrên PostgreSQL. - Tài nguyên. CPU, I/O, đĩa, số connection. I/O tăng vọt đi cùng số lần đọc disk tăng nghĩa là có thứ gì đó vừa đẩy working set ra khỏi RAM.
Cách gỡ nhanh thường là dừng nguồn gây tải (tắt job, rollback deploy, kill query hay transaction đang chặn), rồi mới tìm nguyên nhân gốc.
Ghi nhớ
- Theo dõi tải, độ trễ, lỗi và tài nguyên; đĩa thì cảnh báo theo tốc độ đầy.
- Transaction mở lâu chặn việc dọn phiên bản cũ: history list length tăng trên MySQL, VACUUM không xoá được dead tuple trên PostgreSQL. Theo dõi tuổi transaction cũ nhất.
- Tìm query cần tối ưu bằng thống kê gom theo mẫu, xếp theo tổng thời gian; tỉ lệ dòng đọc trên dòng trả cao là dấu hiệu thiếu index.
- Hit rate gần như luôn trên 99%; theo dõi số lần đọc disk mỗi giây thay vì phần trăm.
- Chỉ gọi người dậy khi người dùng đang bị ảnh hưởng mà không tự hồi phục; khi có sự cố, hỏi "vừa thay đổi gì" trước.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-23):
| Nguồn | Dùng cho phần |
|---|---|
| PostgreSQL 17 docs, pg_stat_statements | Phải thêm vào shared_preload_libraries |
Đọc trên lab: mặc định long_query_time = 10, slow_query_log = 0, performance_schema = 1, max_connections = 151 (MySQL 8.0.46); max_connections = 100, log_min_duration_statement = -1 (PostgreSQL 17). Từ kiến thức chung, chưa đối chiếu lần này: idle_in_transaction_session_timeout, auto_explain, sys.innodb_lock_waits, cách viết cảnh báo theo triệu chứng (theo sách Site Reliability Engineering của Google).
Số đo chi tiết
Đo bằng script lab/giam-sat/top-queries.mjs ngày 2026-10-23 trên MacBook M2 (MySQL 8.0, PostgreSQL 17 trong Docker), hai lượt. Bảng users 200.000 dòng (không có index trên email), orders 500.000 dòng. Tải 10 giây: một vòng lặp N+1 (lấy 20 đơn rồi lấy user của từng đơn), một vòng lặp tìm theo email mỗi 0,5 giây, một vòng lặp lấy đơn theo id.
Thống kê theo mẫu query
| Mẫu query | MySQL: lần gọi, tổng, trung bình | MySQL: dòng đọc / trả | PostgreSQL: lần gọi, tổng, trung bình | PostgreSQL: block mỗi lần |
|---|---|---|---|---|
orders WHERE id = ? | 25.944–26.460, 927–943 ms, 0,036 ms | 1 | 26.345–26.365, 226–233 ms, 0,009 ms | 4 |
users WHERE id = ? (N+1) | 24.720–25.220, 837–871 ms, 0,033–0,035 ms | 1 | 25.100–25.140, 163–167 ms, 0,007 ms | 4 |
users WHERE email = ? | 19–20, 500–530 ms, 25–28 ms | 200.000 | 20, 166–175 ms, 8,3–8,8 ms | 1.657 |
orders ORDER BY id DESC LIMIT ? | 1.236–1.261, 55–61 ms | 1 | 1.255–1.257, 12–13 ms | 4 |
Transaction mở lâu và hit rate
- MySQL: history list length từ 2–38 lên 5.001 sau 5.000 transaction
UPDATEmột dòng, trong lúc một transaction khác mở (INNODB_TRXthấy nó, cũ nhất 4 giây); về 0 sau 0,6 giây kể từ khi transaction đó commit. - PostgreSQL: sau
UPDATE50.000 dòng rồiVACUUM,n_dead_tupvẫn là 50.000 khi còn một phiênidle in transaction; về 0 sau khi phiên đó commit vàVACUUMlại. - Hit rate sau đợt tải: MySQL 99,90–99,91% (khoảng 120.000 lần đọc disk trên 125–128 triệu lần đọc page, phần lớn do query thiếu index quét cả bảng), PostgreSQL 99,98%.
Code đã dùng để chạy thử
Chạy bằng npm run lab -- giam-sat sau npm run lab:up, khoảng 30 giây. Script dựng tạm container lab-pg-stat (PostgreSQL có pg_stat_statements) rồi xoá. Xem Lab.
lab/giam-sat/top-queries.mjs
js
// Giám sát: tìm query cần tối ưu bằng thống kê gom theo mẫu query, và hai chỉ số hay bị bỏ qua.
// 1. Tải giả lập trong khoảng 10 giây: N+1 (rất nhiều query nhỏ), một query thiếu index (ít lần nhưng chậm), query bình thường.
// Xem MySQL performance_schema.events_statements_summary_by_digest và PostgreSQL pg_stat_statements xếp hạng ra sao.
// 2. Transaction mở lâu chặn việc dọn phiên bản cũ: MySQL history list length (đếm theo transaction, không theo dòng)
// tăng theo số transaction ghi chạy trong lúc đó; PostgreSQL VACUUM không xoá được dead tuple.
// 3. Hit rate của buffer pool (MySQL) và shared buffers (PostgreSQL).
// pg_stat_statements cần nạp lúc khởi động, nên script dựng tạm container lab-pg-stat (cổng 55433) rồi xoá.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { execFileSync } from 'node:child_process'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const docker = (...a) => execFileSync('docker', a, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
const fmt = (n) => Math.round(Number(n)).toLocaleString('vi-VN')
// Tải chung cho cả hai engine. q(sql, args) chạy một câu.
async function workload(q, seconds = 10) {
const end = performance.now() + seconds * 1000
const n1 = (async () => { // trang danh sách đơn: 1 query lấy 20 đơn, rồi 20 query lấy user của từng đơn
while (performance.now() < end) {
const orders = await q('SELECT id, user_id FROM orders ORDER BY id DESC LIMIT 20')
for (const o of orders) await q('SELECT id, name FROM users WHERE id = ?', [o.user_id])
}
})()
const slow = (async () => { // báo cáo tìm theo email: thiếu index nên quét cả bảng
while (performance.now() < end) { await q('SELECT COUNT(*) AS n FROM users WHERE email = ?', [`u${Math.floor(Math.random() * 200000)}@example.com`]); await sleep(500) }
})()
const normal = (async () => {
while (performance.now() < end) await q('SELECT id, total FROM orders WHERE id = ?', [1 + Math.floor(Math.random() * 500000)])
})()
await Promise.all([n1, slow, normal])
}
// ---------- MySQL ----------
ensureDb('mysql', 'monitor')
const my = await mysql.createConnection({ ...MYSQL, database: 'monitor' })
await my.query('DROP TABLE IF EXISTS orders, users')
await my.query('CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, email VARCHAR(100) NOT NULL)')
await my.query('CREATE TABLE orders (id INT PRIMARY KEY, user_id INT NOT NULL, total INT NOT NULL)')
await my.query('SET SESSION cte_max_recursion_depth = 1000000')
await my.query(`INSERT INTO users WITH RECURSIVE s(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM s WHERE i < 200000) SELECT i, CONCAT('user ', i), CONCAT('u', i, '@example.com') FROM s`)
await my.query(`INSERT INTO orders WITH RECURSIVE s(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM s WHERE i < 500000) SELECT i, 1 + i % 200000, i % 1000 FROM s`)
await my.query('TRUNCATE performance_schema.events_statements_summary_by_digest')
const pool = mysql.createPool({ ...MYSQL, database: 'monitor', connectionLimit: 4 })
await workload(async (s, a) => (await pool.query(s, a))[0])
await pool.end()
const [top] = await my.query(`SELECT DIGEST_TEXT AS q, COUNT_STAR AS calls, ROUND(SUM_TIMER_WAIT / 1e9) AS total_ms,
ROUND(AVG_TIMER_WAIT / 1e6) / 1000 AS avg_ms, SUM_ROWS_EXAMINED AS examined, SUM_ROWS_SENT AS sent
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = 'monitor' AND DIGEST_TEXT LIKE 'SELECT%' ORDER BY SUM_TIMER_WAIT DESC LIMIT 4`)
console.log('\n=== MySQL 8.0: performance_schema, gom theo mẫu query, xếp theo tổng thời gian (10 giây tải) ===')
console.log('lần gọi tổng ms trung bình ms dòng đọc / dòng trả mẫu query')
for (const r of top) console.log(`${fmt(r.calls).padStart(7)} ${fmt(r.total_ms).padStart(9)} ${String(r.avg_ms).replace('.', ',').padStart(15)} ${(fmt(Number(r.examined) / Math.max(1, Number(r.sent)))).padStart(21)} ${r.q.slice(0, 70)}`)
// History list length khi có transaction mở lâu
const hll = async () => Number((await my.query(`SELECT COUNT AS c FROM information_schema.INNODB_METRICS WHERE NAME = 'trx_rseg_history_len'`))[0][0].c)
const longTx = await mysql.createConnection({ ...MYSQL, database: 'monitor' })
await longTx.query('START TRANSACTION WITH CONSISTENT SNAPSHOT')
await longTx.query('SELECT COUNT(*) FROM orders WHERE id < 10')
const h0 = await hll()
const wpool = mysql.createPool({ ...MYSQL, database: 'monitor', connectionLimit: 8 })
await Promise.all(Array.from({ length: 8 }, async (_, w) => { for (let i = w; i < 5000; i += 8) await wpool.query('UPDATE orders SET total = total + 1 WHERE id = ?', [1 + i]) }))
await wpool.end()
await sleep(1000)
const h1 = await hll()
const [[tx]] = await my.query(`SELECT COUNT(*) AS n, MAX(TIMESTAMPDIFF(SECOND, trx_started, NOW())) AS oldest FROM information_schema.INNODB_TRX`)
await longTx.query('COMMIT')
await longTx.end()
const tc = performance.now()
let h2 = await hll()
while (h2 > h0 + 100 && performance.now() - tc < 20000) { await sleep(200); h2 = await hll() }
console.log(`\nHistory list length: ${fmt(h0)} lúc đầu, ${fmt(h1)} sau 5.000 transaction UPDATE một dòng trong lúc có một transaction mở (INNODB_TRX: ${tx.n} transaction, cũ nhất ${tx.oldest} giây), về ${fmt(h2)} sau ${Math.round((performance.now() - tc) / 100) / 10} giây kể từ khi transaction đó commit`.replace(/(\d)\.(\d) giây/, '$1,$2 giây'))
const st = Object.fromEntries((await my.query(`SHOW GLOBAL STATUS WHERE Variable_name IN ('Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads')`))[0].map((r) => [r.Variable_name, Number(r.Value)]))
console.log(`Buffer pool: ${fmt(st.Innodb_buffer_pool_read_requests)} lần đọc page, ${fmt(st.Innodb_buffer_pool_reads)} lần phải đọc từ disk, hit rate ${(100 * (1 - st.Innodb_buffer_pool_reads / st.Innodb_buffer_pool_read_requests)).toFixed(2).replace('.', ',')}%`)
await my.end()
// ---------- PostgreSQL với pg_stat_statements ----------
try { docker('rm', '-f', 'lab-pg-stat') } catch {}
docker('run', '-d', '--name', 'lab-pg-stat', '-e', 'POSTGRES_PASSWORD=pw', '-p', '127.0.0.1:55433:5432', 'postgres:17-alpine', '-c', 'shared_preload_libraries=pg_stat_statements')
let p
for (let i = 0; i < 60; i++) { try { p = new pg.Client({ ...PG, port: 55433 }); await p.connect(); await p.query('SELECT 1'); break } catch { try { await p.end() } catch {} p = null; await sleep(500) } }
try {
await p.query('CREATE EXTENSION pg_stat_statements')
await p.query('CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, email VARCHAR(100) NOT NULL)')
await p.query('CREATE TABLE orders (id INT PRIMARY KEY, user_id INT NOT NULL, total INT NOT NULL)')
await p.query(`INSERT INTO users SELECT i, 'user ' || i, 'u' || i || '@example.com' FROM generate_series(1, 200000) AS i`)
await p.query('INSERT INTO orders SELECT i, 1 + i % 200000, i % 1000 FROM generate_series(1, 500000) AS i')
await p.query('VACUUM ANALYZE users')
await p.query('VACUUM ANALYZE orders')
await p.query('SELECT pg_stat_statements_reset()')
const ppool = new pg.Pool({ ...PG, port: 55433, max: 4 })
const conv = (s) => { let i = 0; return s.replace(/\?/g, () => `$${++i}`) }
await workload(async (s, a) => (await ppool.query(conv(s), a)).rows)
await ppool.end()
const { rows } = await p.query(`SELECT query, calls, round(total_exec_time) AS total_ms, round(mean_exec_time::numeric, 3) AS avg_ms, rows,
shared_blks_hit, shared_blks_read FROM pg_stat_statements WHERE query LIKE 'SELECT%' AND query NOT LIKE '%pg_stat%' ORDER BY total_exec_time DESC LIMIT 4`)
console.log('\n=== PostgreSQL 17: pg_stat_statements, xếp theo tổng thời gian (10 giây tải) ===')
console.log('lần gọi tổng ms trung bình ms block đọc mỗi lần gọi mẫu query')
for (const r of rows) console.log(`${fmt(r.calls).padStart(7)} ${fmt(r.total_ms).padStart(9)} ${String(r.avg_ms).replace('.', ',').padStart(15)} ${fmt((Number(r.shared_blks_hit) + Number(r.shared_blks_read)) / Number(r.calls)).padStart(23)} ${r.query.slice(0, 70)}`)
const { rows: [b] } = await p.query(`SELECT blks_hit, blks_read FROM pg_stat_database WHERE datname = current_database()`)
console.log(`Shared buffers: hit rate ${(100 * Number(b.blks_hit) / (Number(b.blks_hit) + Number(b.blks_read))).toFixed(2).replace('.', ',')}% (${fmt(b.blks_hit)} hit, ${fmt(b.blks_read)} read)`)
// Transaction mở lâu chặn VACUUM dọn dead tuple
const lt = new pg.Client({ ...PG, port: 55433 })
await lt.connect()
await lt.query('BEGIN ISOLATION LEVEL REPEATABLE READ')
await lt.query('SELECT COUNT(*) FROM orders WHERE id < 10')
await p.query('UPDATE orders SET total = total + 1 WHERE id <= 50000')
await p.query('VACUUM orders')
const dead = async () => Number((await p.query(`SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'orders'`)).rows[0].n_dead_tup)
await sleep(700) // thống kê được gửi định kỳ
const d1 = await dead()
const { rows: [act] } = await p.query(`SELECT COUNT(*) AS n, MAX(EXTRACT(EPOCH FROM now() - xact_start))::int AS oldest FROM pg_stat_activity WHERE state = 'idle in transaction'`)
await lt.query('COMMIT')
await lt.end()
await p.query('VACUUM orders')
await sleep(700)
console.log(`Dead tuple sau UPDATE 50.000 dòng rồi VACUUM: ${fmt(d1)} khi còn một transaction mở (pg_stat_activity: ${act.n} phiên 'idle in transaction', cũ nhất ${act.oldest} giây), ${fmt(await dead())} sau khi nó commit và VACUUM lại`)
} finally {
await p.end()
docker('rm', '-f', 'lab-pg-stat')
}