Skip to content

Bộ đếm, hot row & bảng tổng hợp ​

Lượt thích, lượt xem, số người tham gia, tồn kho, bảng xếp hạng: đây là những con số mà rất nhiều request cùng muốn cập nhật, hoặc phải đếm trên một bảng rất lớn. Trang này giải thích vì sao một dòng được nhiều người cùng ghi trở thành nút thắt (hot row), các cách chia nhỏ hay gom lệnh ghi, cách đếm trên bảng lớn, và cách làm bảng xếp hạng.

Hot row: một dòng, nhiều người cùng ghi ​

sql
UPDATE posts SET likes = likes + 1 WHERE id = ?;

Câu này đúng: database tự khoá dòng nên không mất lượt đếm nào (Race condition). Nhưng chính lock đó giới hạn tốc độ. Lệnh UPDATE giữ lock của dòng tới khi transaction commit, và lệnh thứ hai phải chờ lệnh thứ nhất commit xong mới được sửa. Mọi lệnh ghi vào dòng đó vì vậy xếp hàng một một. Throughput tối đa của một dòng xấp xỉ 1 chia thời gian giữ lock. Với autocommit, thời gian này chủ yếu là lúc commit chờ ghi log xuống disk, nên một dòng chỉ chịu được vài trăm tới vài nghìn lệnh mỗi giây, dù database còn rất rảnh.

Điều tệ hơn là cập nhật bộ đếm ở đầu một transaction dài. Lock giữ từ lúc UPDATE tới lúc commit, nên nếu sau đó transaction còn query thêm hay gọi API, mọi request khác phải chờ suốt khoảng đó. Trên lab, chỉ cần transaction kéo dài thêm 5 ms là bộ đếm tụt xuống khoảng 150 lệnh mỗi giây, bất kể database nào.

Thời gian giữ lock theo
787lệnh/s tối đa với một dòng
12.598lệnh/s tối đa với 16 dòng
011.00022.0001248163264số dòng của bộ đếm (thang log)
trần lý thuyết: min(số dòng / thời gian giữ lock, số connection / thời gian một vòng)đo thật, 32 connection, 2 lượt

Với một dòng, mỗi lệnh phải chờ lệnh trước commit xong, nên trần chỉ là 1 chia thời gian giữ lock. Chia ra nhiều dòng thì nhiều lệnh đi song song. Số đo thật tăng chậm hơn đường lý thuyết, vì các lệnh vẫn dùng chung phần commit xuống disk và việc đánh thức transaction đang chờ lock cũng tốn thời gian.

Thời gian giữ lock của từng engine suy từ số đo với một dòng; mạng 0,3 ms mỗi vòng.

Cách xử lý hot row ​

1. Giữ lock ngắn nhất có thể. Đặt lệnh cập nhật bộ đếm ở cuối transaction, ngay trước commit, hoặc tách nó ra transaction riêng nếu không cần nguyên tử với phần còn lại. Đây là cách rẻ nhất và nên làm trước.

2. Chia bộ đếm ra nhiều dòng. Một bộ đếm thành N dòng. Mỗi lệnh ghi chọn ngẫu nhiên một dòng, còn lệnh đọc thì cộng lại:

sql
CREATE TABLE post_likes (
  post_id INT NOT NULL,
  slot    INT NOT NULL,           -- 0 .. N-1
  likes   BIGINT NOT NULL DEFAULT 0,
  PRIMARY KEY (post_id, slot)
);

UPDATE post_likes SET likes = likes + 1 WHERE post_id = ? AND slot = ?;  -- slot ngẫu nhiên
SELECT SUM(likes) FROM post_likes WHERE post_id = ?;                      -- đọc

N lệnh có thể đi song song, nên throughput tăng theo N. Nhưng không tăng đủ N lần, vì các lệnh vẫn dùng chung phần commit xuống disk. Trên lab, chia ra 64 dòng thì MySQL tăng khoảng 7 lần, PostgreSQL khoảng 5 lần. Cái giá là lệnh đọc phải cộng N dòng, và mọi chỗ khởi tạo bộ đếm phải tạo đủ N dòng.

3. Gom lệnh ghi. Không ghi vào database mỗi lượt thích, mà đếm ở chỗ nhanh hơn rồi định kỳ cộng dồn vào database:

  • Đếm bằng Redis INCR, một job mỗi vài giây đọc và cộng dồn vào database bằng một lệnh UPDATE … SET likes = likes + ?.
  • Hoặc đệm ngay trong process của app (một Map từ post_id tới số lượt), flush định kỳ.

Cách này chịu được lượng ghi rất lớn, đổi lại số trong database chậm vài giây so với thực tế, và có thể mất phần chưa flush nếu Redis hay process chết. Chỉ hợp với số không cần đúng tuyệt đối như lượt xem, lượt thích. Không dùng cho tồn kho hay số dư.

4. Ghi từng sự kiện, đếm sau. Mỗi lượt thích là một dòng mới trong bảng likes (chỉ thêm, không ai sửa cùng một dòng), và một job định kỳ tổng hợp ra số đếm. Không còn hot row, vì mỗi lệnh ghi một dòng khác nhau. Cách này còn có lợi là biết được ai đã thích, và chống đếm trùng được (phần cuối trang).

CáchChịu được bao nhiêu lệnh ghiSố đọc raHợp với
Một dòng, giữ lock ngắnVài trăm tới vài nghìn mỗi giâyĐúng ngayĐa số trường hợp
Chia N dòngGấp vài lần một dòngĐúng ngay, đọc phải cộng N dòngBộ đếm nóng nhưng cần đúng (tồn kho flash sale)
Gom qua Redis hoặc bộ đệm trong appRất lớnChậm vài giây, có thể mất phần chưa flushLượt xem, lượt thích
Bảng sự kiện + job tổng hợpRất lớnChậm tới lần tổng hợp tiếp theoCần biết từng sự kiện, cần chống trùng

Đếm trên bảng lớn ​

SELECT COUNT(*) FROM orders trông đơn giản nhưng không có sẵn đáp án. Tài liệu MySQL giải thích InnoDB không lưu sẵn số dòng của bảng, vì các transaction chạy cùng lúc có thể "thấy" số dòng khác nhau (MVCC). PostgreSQL cũng vậy. Mỗi lần COUNT(*) database phải đi qua cả một index, nên thời gian tăng tuyến tính theo số dòng: vài chục ms với vài triệu dòng, nhưng vài chục giây với vài tỷ dòng.

Các cách thường dùng:

  • Đếm theo điều kiện có index. COUNT(*) WHERE post_id = ? chỉ đi qua phần index của một bài viết, nên nhanh.
  • Bảng counter cập nhật cùng transaction với lệnh thêm hay xoá dòng. Số luôn đúng, nhưng bảng counter lại thành hot row nếu nhiều người cùng ghi, và áp dụng được các cách ở phần trên.
  • Cập nhật counter qua CDC hay job đọc thay đổi của bảng gốc (CDC), chấp nhận chậm vài giây.
  • Dùng số ước tính. MySQL có information_schema.TABLES.TABLE_ROWS, PostgreSQL có pg_class.reltuples. Cả hai lấy từ thống kê nên trả về ngay, nhưng có thể lệch: tài liệu MySQL nói số của InnoDB có thể lệch 40–50%. Hợp với giao diện kiểu "khoảng 2 triệu kết quả".
  • Không hiện tổng chính xác, mà hiện "hơn 1.000" hay chỉ có nút trang sau (Phân trang).

Bảng tổng hợp ​

Báo cáo doanh thu theo ngày, số đơn theo trạng thái, lượt xem theo giờ: tính lại từ dữ liệu gốc mỗi lần mở dashboard thì tốn kém và chậm dần khi bảng lớn lên. Bảng tổng hợp lưu sẵn kết quả, được cập nhật bằng upsert theo từng khoảng thời gian. Cách làm chi tiết, kể cả lý do nên lưu tổng và số mẫu thay vì trung bình, ở trang Dữ liệu chuỗi thời gian.

  • PostgreSQL có materialized view: một view mà kết quả được lưu lại như bảng, làm mới bằng REFRESH MATERIALIZED VIEW. Mặc định lệnh này chặn mọi lệnh đọc view trong lúc làm mới. Thêm CONCURRENTLY thì không chặn đọc, nhưng view phải có ít nhất một UNIQUE index trên các cột thường. Materialized view luôn tính lại toàn bộ, nên với dữ liệu lớn thì bảng tổng hợp tự cập nhật theo từng khoảng vẫn rẻ hơn.
  • MySQL không có materialized view. Dùng bảng thường, cập nhật bằng job hoặc event định kỳ (View, trigger, stored procedure).

Bảng xếp hạng ​

Top N rất rẻ khi có index trên điểm: ORDER BY score DESC LIMIT 10 chỉ đọc 10 mục đầu của index.

Hạng của một người cụ thể thì đắt hơn nhiều: SELECT COUNT(*) + 1 FROM scores WHERE score > ? phải đếm mọi người có điểm cao hơn. Người đứng đầu tính ngay, nhưng người đứng thứ một triệu phải đi qua cả triệu mục index. Trên lab, câu này mất khoảng 17–77 ms với bảng 1 triệu người, và tăng tuyến tính theo hạng.

Khi cần hạng của bất kỳ ai với tải lớn, Redis sorted set hợp hơn: ZADD cập nhật điểm, ZREVRANK trả về hạng, cả hai O(log N) (Redis). Database vẫn là nơi lưu điểm chính thức, còn sorted set là bản sao để tra hạng nhanh. Điểm bằng nhau thì cần quy tắc phân hạng rõ ràng, ví dụ ai đạt điểm trước thì đứng trên, và đưa quy tắc đó vào khoá sắp xếp.

Chống đếm trùng ​

Một người thích một bài viết hai lần (bấm nhanh hai lần, request bị gửi lại) không được tính hai lượt. Cách chắc chắn là để database chặn:

sql
CREATE TABLE likes (
  post_id INT NOT NULL,
  user_id INT NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (post_id, user_id)      -- mỗi người chỉ thích một bài một lần
);

-- MySQL: INSERT IGNORE … ; PostgreSQL: INSERT … ON CONFLICT DO NOTHING
-- Chỉ tăng bộ đếm khi lệnh insert thật sự thêm được một dòng (affectedRows = 1), trong cùng transaction

Đếm số người xem duy nhất trên lượng rất lớn thì lưu từng cặp (người, bài) tốn nhiều chỗ. Redis HyperLogLog đếm xấp xỉ với sai số khoảng 0,81% và tối đa 12 KB mỗi key (Redis).

Ghi nhớ

  1. UPDATE … SET n = n + 1 không mất lượt nhưng xếp hàng trên lock của dòng tới khi commit, nên một dòng chỉ chịu được khoảng 1 chia thời gian giữ lock lệnh mỗi giây.
  2. Cập nhật bộ đếm ở cuối transaction. Cần hơn nữa thì chia bộ đếm ra nhiều dòng, hoặc gom lệnh ghi qua Redis khi chấp nhận được số chậm vài giây.
  3. COUNT(*) trên bảng lớn phải quét cả index vì MVCC. Dùng bảng counter, số ước tính, hoặc không hiện tổng chính xác.
  4. Top N rẻ nhờ index, nhưng hạng của một người tốn tuyến tính theo hạng; cần tra hạng nhiều thì dùng Redis sorted set.
  5. Chống đếm trùng bằng khoá chính (post_id, user_id), và chỉ tăng bộ đếm khi insert thật sự thêm được dòng.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-23):

NguồnDùng cho phần
MySQL 8.0 Reference Manual, Aggregate Function DescriptionsInnoDB không lưu sẵn số dòng vì các transaction chạy cùng lúc có thể thấy số dòng khác nhau
MySQL 8.0 Reference Manual, The INFORMATION_SCHEMA TABLES TableTABLE_ROWS của InnoDB chỉ là ước tính, có thể lệch 40–50%
PostgreSQL 17 docs, REFRESH MATERIALIZED VIEWCONCURRENTLY cần ít nhất một UNIQUE index trên các cột thường, không có biểu thức hay WHERE

Chưa đối chiếu lần này: REFRESH MATERIALIZED VIEW không có CONCURRENTLY thì chặn đọc (từ kiến thức chung); độ phức tạp O(log N) của ZADD, ZREVRANK (tài liệu từng lệnh của Redis).

Số đo chi tiết ​

Đo bằng script lab/bo-dem/ ngày 2026-10-23 trên MacBook M2 (MySQL 8.0, PostgreSQL 17 trong Docker), hai lượt.

Hot row: 32 connection cùng tăng một bộ đếm, mỗi phép đo 3 giây
Bộ đếmMySQL 8.0PostgreSQL 17
1 dòng756–802 lệnh/s3.626–3.687 lệnh/s
Chia 4 dòng1.360–1.8446.876–6.958
Chia 16 dòng2.609–3.56914.551–14.885
Chia 64 dòng5.277–5.30917.878–18.512
1 dòng, cập nhật ở đầu transaction 5 ms134–145153–157
1 dòng, cập nhật ở cuối transaction 5 ms527–5421.844–1.925

Tổng đọc lại sau mỗi phép đo luôn bằng đúng số lệnh đã chạy, tức không mất lượt nào. MySQL chậm hơn PostgreSQL vì mỗi commit phải ghi cả redo log lẫn binlog xuống disk.

Đếm và xếp hạng
MySQL 8.0PostgreSQL 17
COUNT(*) bảng likes 2.000.000 dòng33,6 ms20,8 ms
COUNT(*) một bài viết (2.000 dòng, theo khoá chính)0,5 ms0,7 ms
Số ước tính (TABLE_ROWS, reltuples)2.171.222, lệch 8,6%2.000.000, đúng sau VACUUM ANALYZE
Top 10 theo index trên score (1.000.000 người)0,4 ms0,9 ms
Hạng của người đứng đầu0,4 ms0,4 ms
Hạng của người đứng cuối (COUNT(*) WHERE score > ?)76,6 ms16,6 ms

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- bo-dem sau npm run lab:up, khoảng 1 phút. Xem Lab.

lab/bo-dem/hot-row.mjs: hot row, chia nhỏ bộ đếm, vị trí lệnh cập nhật trong transaction
js
// Hot row: 32 connection cùng tăng một bộ đếm. So sánh:
//   - một dòng (UPDATE posts SET likes = likes + 1 WHERE id = 1)
//   - bộ đếm chia N dòng: mỗi lệnh chọn ngẫu nhiên một trong N dòng, đọc thì cộng lại
//   - cập nhật bộ đếm ở đầu một transaction còn làm việc khác 5 ms (lock bị giữ suốt transaction)
// Mỗi phép đo 3 giây, trên MySQL 8.0 và PostgreSQL 17.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'

const SECONDS = 3, CONC = 32
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const fmt = (n) => Math.round(n).toLocaleString('vi-VN')

async function load(fn) {
  let n = 0
  const end = performance.now() + SECONDS * 1000
  await Promise.all(Array.from({ length: CONC }, async () => { while (performance.now() < end) { await fn(); n++ } }))
  return n / SECONDS
}

ensureDb('mysql', 'bo_dem')
ensureDb('pg', 'bo_dem')

const engines = {
  'MySQL 8.0': async () => {
    const pool = mysql.createPool({ ...MYSQL, database: 'bo_dem', connectionLimit: CONC })
    const q = (sql, args) => pool.query(sql, args)
    const tx = async (fn) => { const c = await pool.getConnection(); try { await c.query('BEGIN'); await fn((s, a) => c.query(s, a)); await c.query('COMMIT') } finally { c.release() } }
    return { q, tx, end: () => pool.end() }
  },
  'PostgreSQL 17': async () => {
    const pool = new pg.Pool({ ...PG, database: 'bo_dem', max: CONC })
    const conv = (s) => { let i = 0; return s.replace(/\?/g, () => `$${++i}`) }
    const q = (sql, args) => pool.query(conv(sql), args)
    const tx = async (fn) => { const c = await pool.connect(); try { await c.query('BEGIN'); await fn((s, a) => c.query(conv(s), a)); await c.query('COMMIT') } finally { c.release() } }
    return { q, tx, end: () => pool.end() }
  }
}

for (const [name, make] of Object.entries(engines)) {
  const db = await make()
  await db.q('DROP TABLE IF EXISTS post_likes')
  await db.q('CREATE TABLE post_likes (post_id INT NOT NULL, slot INT NOT NULL, likes BIGINT NOT NULL, PRIMARY KEY (post_id, slot))')
  console.log(`\n=== ${name}: ${CONC} connection cùng tăng bộ đếm của một bài viết, mỗi phép đo ${SECONDS} giây ===`)
  for (const slots of [1, 4, 16, 64]) {
    await db.q('DELETE FROM post_likes')
    for (let s = 0; s < slots; s++) await db.q('INSERT INTO post_likes VALUES (1, ?, 0)', [s])
    const rate = await load(() => db.q('UPDATE post_likes SET likes = likes + 1 WHERE post_id = 1 AND slot = ?', [Math.floor(Math.random() * slots)]))
    const [[{ total }]] = name.startsWith('MySQL')
      ? await db.q('SELECT SUM(likes) AS total FROM post_likes WHERE post_id = 1')
      : [(await db.q('SELECT SUM(likes) AS total FROM post_likes WHERE post_id = 1')).rows]
    console.log(`${String(slots).padStart(2)} dòng: ${fmt(rate).padStart(6)} lệnh/s   (tổng đọc lại: ${Number(total).toLocaleString('vi-VN')})`)
  }
  // Bộ đếm cập nhật ở đầu transaction, sau đó app làm việc khác 5 ms rồi mới commit.
  await db.q('DELETE FROM post_likes')
  await db.q('INSERT INTO post_likes VALUES (1, 0, 0)')
  const early = await load(() => db.tx(async (run) => { await run('UPDATE post_likes SET likes = likes + 1 WHERE post_id = 1 AND slot = 0'); await sleep(5) }))
  const late = await load(() => db.tx(async (run) => { await sleep(5); await run('UPDATE post_likes SET likes = likes + 1 WHERE post_id = 1 AND slot = 0') }))
  console.log(`1 dòng, transaction 5 ms: cập nhật ở đầu ${fmt(early)} lệnh/s, cập nhật ở cuối ${fmt(late)} lệnh/s`)
  await db.end()
}
lab/bo-dem/count-rank.mjs: COUNT(*), số ước tính, hạng trong bảng xếp hạng
js
// Đếm trên bảng lớn và bảng xếp hạng, trên MySQL 8.0 và PostgreSQL 17.
//   1. Bảng likes 2.000.000 dòng (UNIQUE (post_id, user_id)): COUNT(*) cả bảng, COUNT một bài viết có index,
//      số ước tính của database (TABLE_ROWS, reltuples) so với số thật.
//   2. Bảng scores 1.000.000 người chơi, index trên score: lấy top 10, và tính hạng của một người
//      bằng COUNT(*) WHERE score > ? (người đứng đầu so với người đứng cuối).
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'

const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(1).replace('.', ',')) + ' ms'
async function best(fn, n = 3) { let m = Infinity, r; for (let i = 0; i < n; i++) { const t0 = performance.now(); r = await fn(); m = Math.min(m, performance.now() - t0) } return [m, r] }
const fmt = (n) => Number(n).toLocaleString('vi-VN')

ensureDb('mysql', 'bo_dem')
ensureDb('pg', 'bo_dem')

// ---------- MySQL ----------
const my = await mysql.createConnection({ ...MYSQL, database: 'bo_dem' })
const one = async (sql, args) => Object.values((await my.query(sql, args))[0][0])[0]
await my.query('DROP TABLE IF EXISTS likes, scores')
await my.query('CREATE TABLE likes (post_id INT NOT NULL, user_id INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (post_id, user_id))')
await my.query('SET SESSION cte_max_recursion_depth = 2000000')
await my.query(`INSERT INTO likes (post_id, user_id) WITH RECURSIVE s(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM s WHERE i < 1999999)
  SELECT i % 1000, i DIV 1000 FROM s`)
await my.query('CREATE TABLE scores (user_id INT PRIMARY KEY, score INT NOT NULL, INDEX idx_score (score))')
await my.query(`INSERT INTO scores WITH RECURSIVE s(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM s WHERE i < 1000000)
  SELECT i, (i * 7919) % 10000000 FROM s`)
await my.query('ANALYZE TABLE likes, scores')
console.log('\n=== MySQL 8.0 ===')
let [t, n] = await best(() => one('SELECT COUNT(*) FROM likes'))
console.log(`COUNT(*) cả bảng likes: ${fmt(n)} dòng, ${ms(t)}`)
;[t, n] = await best(() => one('SELECT COUNT(*) FROM likes WHERE post_id = 7'))
console.log(`COUNT(*) một bài viết (dùng khoá chính): ${fmt(n)} dòng, ${ms(t)}`)
const est = await one(`SELECT TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'bo_dem' AND TABLE_NAME = 'likes'`)
console.log(`Số ước tính information_schema.TABLES.TABLE_ROWS: ${fmt(est)} (lệch ${((est / 2e6 - 1) * 100).toFixed(1).replace('.', ',')}%)`)
const [ttop] = await best(() => my.query('SELECT user_id, score FROM scores ORDER BY score DESC LIMIT 10'))
const top = await one('SELECT MAX(score) FROM scores'), bottom = await one('SELECT MIN(score) FROM scores')
const [tr1, r1] = await best(() => one('SELECT COUNT(*) + 1 FROM scores WHERE score > ?', [top]))
const [tr2, r2] = await best(() => one('SELECT COUNT(*) + 1 FROM scores WHERE score > ?', [bottom]))
console.log(`Top 10 theo index: ${ms(ttop)}; hạng của người đứng đầu (hạng ${r1}): ${ms(tr1)}; hạng của người đứng cuối (hạng ${fmt(r2)}): ${ms(tr2)}`)
await my.end()

// ---------- PostgreSQL ----------
const p = new pg.Client({ ...PG, database: 'bo_dem' })
await p.connect()
const pone = async (sql, args) => Object.values((await p.query(sql, args)).rows[0])[0]
await p.query('DROP TABLE IF EXISTS likes, scores')
await p.query('CREATE TABLE likes (post_id INT NOT NULL, user_id INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT now(), PRIMARY KEY (post_id, user_id))')
await p.query('INSERT INTO likes (post_id, user_id) SELECT i % 1000, i / 1000 FROM generate_series(0, 1999999) AS i')
await p.query('CREATE TABLE scores (user_id INT PRIMARY KEY, score INT NOT NULL)')
await p.query('INSERT INTO scores SELECT i, (i::bigint * 7919) % 10000000 FROM generate_series(1, 1000000) AS i')
await p.query('CREATE INDEX idx_score ON scores (score)')
await p.query('VACUUM ANALYZE likes')
await p.query('VACUUM ANALYZE scores')
console.log('\n=== PostgreSQL 17 ===')
;[t, n] = await best(() => pone('SELECT COUNT(*) FROM likes'))
console.log(`COUNT(*) cả bảng likes: ${fmt(n)} dòng, ${ms(t)}`)
;[t, n] = await best(() => pone('SELECT COUNT(*) FROM likes WHERE post_id = 7'))
console.log(`COUNT(*) một bài viết (dùng khoá chính): ${fmt(n)} dòng, ${ms(t)}`)
const pest = await pone(`SELECT reltuples::bigint FROM pg_class WHERE relname = 'likes'`)
console.log(`Số ước tính pg_class.reltuples: ${fmt(pest)} (lệch ${((pest / 2e6 - 1) * 100).toFixed(1).replace('.', ',')}%)`)
const [ptop] = await best(() => p.query('SELECT user_id, score FROM scores ORDER BY score DESC LIMIT 10'))
const ptopS = await pone('SELECT MAX(score) FROM scores'), pbot = await pone('SELECT MIN(score) FROM scores')
const [pr1, q1] = await best(() => pone('SELECT COUNT(*) + 1 FROM scores WHERE score > $1', [ptopS]))
const [pr2, q2] = await best(() => pone('SELECT COUNT(*) + 1 FROM scores WHERE score > $1', [pbot]))
console.log(`Top 10 theo index: ${ms(ptop)}; hạng của người đứng đầu (hạng ${q1}): ${ms(pr1)}; hạng của người đứng cuối (hạng ${fmt(q2)}): ${ms(pr2)}`)
await p.end()