Nhật ký kỹ thuật · Hà Nội · Backend & CloudVol. 2026
← Tất cả bài viết
Số 0917 phút đọc

100.000 lượt xem mỗi giây đập vào một dòng MySQL

Một video lên xu hướng và mọi người xem đều chạy cùng một câu vô hại: views = views + 1. CPU vẫn rảnh mà database vẫn chết. Vì sao một hot row tuần tự hóa mọi thứ, phép tính chặn nó quanh 1.000 lượt ghi mỗi giây, ba lối thoát, và nơi duy nhất tuyệt đối không được dùng chúng.

  • system-design
  • mysql
  • redis
  • kafka
  • concurrency
Tranh khắc gỗ lối vào hội chợ: đám đông chen về một cổng quay hẹp, một thư ký vạch dấu kiểm đếm lên bảng phấn, và một người đưa tin chạy mang tờ kiểm đếm tới nhà kế toán.
Trong bài này
  1. Video lên xu hướng
  2. Data-intensive, không phải compute-intensive
  3. Vì sao một dòng có thể kéo sập cả database
  4. Phép tính nhanh về cái trần
  5. Ba lối thoát
  6. 1. Queue, rồi một UPDATE gộp
  7. 2. Redis INCR, flush theo đồng hồ
  8. 3. Chia bộ đếm thành nhiều dòng
  9. Mỗi cách chữa tốn gì
  10. Nơi tuyệt đối không được làm vậy
  11. Scale up trước, scale out sau
  12. Quay lại cổng quay

Video lên xu hướng

Hình dung một app video ngắn. Một streamer vài triệu follower thả một cái link, và trong khoảng mười giây 100.000 người bấm play cùng một clip. Dưới video có một con số nhỏ, bộ đếm lượt xem, và mỗi lượt play chạy cùng một câu rất ngây thơ: UPDATE videos SET views = views + 1 WHERE id = 42. Lần đầu mình nghĩ qua tình huống này, mình với tay lấy đáp án (“đặt một cái queue phía trước”) trước khi giải thích được vì sao database lại sập. Linh cảm của mình bảo là CPU. Không phải CPU.

Bài này đi theo đúng một dòng dữ liệu đó, từ request đầu tiên tới lúc sập, làm phép tính khiến cú sập trở nên có thể dự đoán, rồi đi qua ba cách chữa. Đây là bài thứ bảy, bài cuối của loạt mình học lại Designing Data-Intensive Applications (Kleppmann) bằng cách biến mỗi ý tưởng thành một kịch bản có thể cố tình làm hỏng, và nó mượn từ gần như mọi bài trước: load parameter ở bài 1, row lock ở bài 6, lost update ở bài 2, replica ở bài 5. Nó cũng là đường ngắn nhất nối chương 1 với chương 7 (đánh số theo bản 1).

sự kiện view lúc cao điểm
100k / s
một row lock bị giữ cho mỗi UPDATE
~1 ms
trần của một row, bao nhiêu CPU cũng vậy
~1k / s
lượt ghi sau khi gộp
1 / s

Data-intensive, không phải compute-intensive

Vậy vì sao linh cảm của mình lại bảo là CPU? Vì mình đã bỏ qua ý đầu tiên của cuốn sách. DDIA mở đầu bằng một cách nhìn lại rất dễ gật đầu mà khó thấm: đa số ứng dụng ngày nay là data-intensive, không phải compute-intensive. Sức CPU thô hiếm khi là thứ giới hạn chúng. Thứ giới hạn là lượng dữ liệu, độ phức tạp của nó và tốc độ nó thay đổi. Bộ đếm lượt xem là ca cực đoan: “phép tính” chỉ là cộng 1, và một chiếc laptop làm việc đó cả tỷ lần mỗi giây. Toàn bộ khó khăn nằm ở chỗ số 1 đó được ghi vào đâu.

Cũng chương đó mô tả hộp đồ nghề như một tập viên gạch: database để lưu dữ liệu, cache để nhớ kết quả tốn kém, search index, stream processing để phản ứng với sự kiện, batch processing để nghiền một đống dữ liệu về sau. Phần hữu ích là ranh giới giữa các viên gạch đang mờ dần. Redis là một datastore nhưng cũng làm được message queue; Kafka là message queue với độ bền gần như database. Nên khi ai đó nói “dùng queue đi”, thật ra họ đang nói “ghép hai viên gạch theo cách hợp với tải này”. Nhớ ý đó, vì cách chữa bên dưới chính xác là như vậy.

Vì sao một dòng có thể kéo sập cả database

Nhìn xem câu lệnh cần gì. Để đổi views từ 41.203 thành 41.204, InnoDB lấy một exclusive lock trên dòng 42, sửa, ghi redo log, rồi nhả lock khi commit. Trong lúc lock còn giữ, mọi transaction khác muốn dòng 42 đều phải chờ. Câu lệnh atomic và đúng (đúng là cách chữa bằng atomic write cho lost update ở bài 2: app không đọc-sửa-ghi, nên không lượt tăng nào bị đè). Vấn đề chính nằm ở đó: ở đây “đúng” nghĩa là “từng cái một”. Đây là sợi chỉ mà chương 7 nối tiếp: ghi vào cùng một object phải được tuần tự hóa bằng lock (bài 6 đã cho thấy một lock có thể âm thầm phủ bao nhiêu dòng), và lock biến đồng thời thành hàng đợi.

Tranh in màu chiếc máy đếm tay cơ khí cổ bên cạnh một cái phễu đồng với những đồng xu đổ vào.
Bản VII. Máy đếm tay và cái phễu. Mỗi người một cái bấm là thiết kế ngây thơ; cái phễu gom đồng xu rồi đếm một lượt là cách chữa.

Phép tính nhanh về cái trần

Giả sử mỗi transaction giữ row lock khoảng 1 ms (con số tròn để tính nhẩm: một lần commit gồm cả việc ép log xuống đĩa; hãy tự đo máy của bạn). Lock trên một dòng không được chồng lên nhau, nên một dòng hoàn thành tối đa 1 / 0,001 = ~1.000 lượt ghi mỗi giây. Không phải “mỗi core”. Mỗi dòng. Thêm 64 CPU thì con số không nhúc nhích, vì 64 core đều đang đứng chung một hàng. (Group commit và ổ đĩa nhanh hơn làm 1 ms dịch chuyển, nhưng không xóa được hàng đợi.)

Giờ đến hàng đợi. Định luật Little nói số request trung bình nằm trong hệ thống bằng tốc độ đến nhân với thời gian trung bình mỗi request ở đó: L = λ · T. Coi dòng dữ liệu là một hàng đợi một máy phục vụ với thời gian phục vụ 1 ms (mô hình M/M/1 trong sách giáo khoa, một sự đơn giản hóa, vì traffic thật dồn cục hơn nên còn tệ hơn). Thời gian trong hệ thống là T = 1 / (μ − λ), với μ = 1.000 mỗi giây:

Tốc độ đến λ vào một rowMức sử dụngThời gian trong hệ thống TConnection bị kẹt, L = λ·T
500 / giây50%2 ms1
900 / giây90%10 ms9
990 / giây99%100 ms99
1.000+ / giây100%+tăng không giới hạntất cả
Cái vực là hình dạng của đường cong. Ở mức 99%, riêng một hot row đã giữ 99 connection, đúng bằng một pool 100 điển hình.

Clip của mình không ở mức 990 mỗi giây, mà ở 100.000. Hàng đợi dài thêm khoảng 99.000 request mỗi giây, nên một pool 100 connection (như trong Hình 1) bị giữ kín trong khoảng một mili giây. Sau đó, request cho bất kỳ bảng nào, kể cả trang đăng nhập chẳng liên quan gì tới video, đều chờ một connection không bao giờ rảnh. Chúng timeout. Logic retry chạy. Retry làm mọi thứ tệ hơn.

DWG 07-ASCALE · NTSClient100.000 view/sApp podstatelessconnection pool100 / 100 đang bậnMySQL · InnoDBid = 42X lockhàng đợi chờ lockmỗi commit ≈ 1 msRow lockmỗi lần 1 writerHàng đợi dàingười chờ dồn lạiLatency tăngms thành giâyPool cạn0 connection rảnhSậptrang khác cũng chếtchuyện xảy ra tiếp theo
Hình 1. Chuỗi sụp đổ. Một exclusive row lock tuần tự hóa các writer, người chờ giữ connection của pool, và pool được dùng chung với mọi thứ khác.

Ba lối thoát

Database không chậm; nó đang bị hỏi cùng một câu 100.000 lần mỗi giây. Mọi cách chữa dưới đây làm cùng một việc trong những bộ cánh khác nhau: đừng bắt database ghi từng lượt xem một. Hoặc gom nhiều lượt xem vào một lần ghi, hoặc rải các lần ghi ra nhiều dòng. Một lượt xem là sự kiện nhỏ xíu và có tính giao hoán (thứ tự của hai cái +1 không quan trọng), nên mới được phép làm vậy.

DWG 07-BSCALE · NTSClient100k / sAppkhông ghi DBA · bộ đếm trên RAMRedisINCR views:delta:42100k+ ops/sFlush loopmỗi 1 giâyGETDEL + cộngB · log bền vữngKafkatopic view-eventslog chỉ thêm vàoConsumercộng theo keybatch idempotentMySQL1 UPDATE / giâyviews += 100000100.000 lượt ghi/s lên một row → 1 lượt ghi/s
Hình 2. Hai bộ đệm, một kết quả. Dù viên gạch ở giữa là bộ đếm RAM (A) hay log bền (B), MySQL chỉ thấy một lần ghi đã gộp mỗi chu kỳ thay vì 100.000.

1. Queue, rồi một UPDATE gộp

Đặt một log như Kafka giữa app và database. App thêm vào một sự kiện nhỏ xíu ({video: 42}) rồi trả về ngay, rất nhanh (gửi bất đồng bộ) và không bao giờ chờ row lock. Một consumer đọc sự kiện, cộng dồn theo key trong bộ nhớ, và mỗi giây ghi một câu: views = views + 100000. Một trăm nghìn lần lấy lock thành một. Vì Kafka lưu sự kiện bền vững, consumer chết không mất gì: nó khởi động lại và đọc lại.

“Đọc lại” chính là cái bẫy. Consumer Kafka về bản chất là at-least-once: nếu consumer ghi vào MySQL rồi chết trước khi commit offset, cùng những sự kiện đó đến lần nữa và bộ đếm bị cộng hai lần. Cách phòng thủ chuẩn là làm cho lệnh ghi idempotent bằng cách lưu tiến độ của consumer trong cùng transaction với thay đổi bộ đếm (một batch chỉ thuộc một partition, và dòng consumer_progress được tạo sẵn):

flush-batch.sqlsql
START TRANSACTION;

-- fence: mỗi batch chỉ áp dụng tối đa một lần, kể cả khi Kafka giao lại
UPDATE consumer_progress
   SET last_offset = 91204                       -- offset cao nhất trong batch này
 WHERE topic = 'view-events' AND part = 3
   AND last_offset < 91204;
-- ROW_COUNT() = 0  ->  đã áp dụng rồi: ROLLBACK và đi tiếp

-- một câu cho mọi video có lượt xem trong cửa sổ này
INSERT INTO video_stats (video_id, views)
VALUES (42, 100000), (77, 3120), (91, 8)
AS new
ON DUPLICATE KEY UPDATE views = views + new.views;

COMMIT;
-- khi khởi động VÀ mỗi lần được gán partition, seek tới last_offset + 1
-- đọc từ bảng này; nhờ vậy các batch không chồng lên nhau.
-- offset Kafka tự commit chỉ là gợi ý

Row lock vẫn còn đó, nhưng giờ nó được lấy một lần mỗi giây cho mỗi video thay vì 100.000 lần. (AS new cần MySQL 8.0.19 trở lên; bản cũ hơn viết VALUES(views).)

2. Redis INCR, flush theo đồng hồ

Phiên bản nhẹ hơn bỏ luôn cái log. Redis giữ dữ liệu trên RAM và chạy lệnh lần lượt trên một thread, nên INCR là atomic và một instance xử lý cỡ trăm nghìn thao tác mỗi giây (nhiều hơn khi pipeline). App tăng một bộ đếm trong Redis; một flush loop định kỳ lấy bộ đếm ra và cộng vào MySQL. Bước tinh tế duy nhất là đọc và reset một cách atomic: nếu bạn làm GET rồi DEL, một lệnh INCR chen vào giữa sẽ bị xóa mất mà không ai hay. GETDEL (Redis 6.2+) hoặc một Lua script làm cả hai trong một bước không chia cắt được; Redis cũ hơn thì dùng GETSET key 0.

views.tsts
import Redis from "ioredis";
import mysql from "mysql2/promise";

const redis = new Redis();
const db = mysql.createPool({ host: "db", user: "app", database: "tube", connectionLimit: 10 });

// 1) đường request: hai lệnh O(1) trên RAM, không chạm MySQL
export async function recordView(videoId: number) {
  await redis.multi().incr("views:delta:" + videoId).sadd("views:dirty", videoId).exec();
}

// 2) đọc và reset atomic, trong một script: lấy các id bẩn, lấy delta của từng id rồi xóa nó
const DRAIN = [
  "local ids = redis.call('SPOP', KEYS[1], tonumber(ARGV[1]))",
  "local out = {}",
  "for _, id in ipairs(ids) do",
  "  local d = redis.call('GETDEL', 'views:delta:' .. id)",
  "  if d then out[#out + 1] = id; out[#out + 1] = d end",
  "end",
  "return out",
].join("\n");

async function flushOnce() {
  const flat = (await redis.eval(DRAIN, 1, "views:dirty", 500)) as string[];
  const rows: [number, number][] = [];
  for (let i = 0; i < flat.length; i += 2) rows.push([Number(flat[i]), Number(flat[i + 1])]);
  if (rows.length === 0) return;
  try {
    await db.query(
      "INSERT INTO video_stats (video_id, views) VALUES ? AS new ON DUPLICATE KEY UPDATE views = views + new.views",
      [rows],
    );
  } catch (e) {
    // MySQL từ chối: trả số đếm về Redis để nhịp sau thử lại
    const m = redis.multi();
    for (const [id, n] of rows) m.incrby("views:delta:" + id, n).sadd("views:dirty", id);
    await m.exec();
    throw e;
  }
}

// 3) mỗi giây flush một lần, không bao giờ chồng nhau
export async function flushLoop() {
  for (;;) {
    await flushOnce().catch(console.error);
    await new Promise((r) => setTimeout(r, 1000));
  }
}

3. Chia bộ đếm thành nhiều dòng

Cách thứ ba không thêm thành phần nào. Thay vì một dòng giữ số đếm, giữ N dòng, ví dụ 16. Mỗi lượt xem chọn một ô ngẫu nhiên và chỉ tăng ô đó; tổng là SUM của các ô. Mười sáu row lock độc lập nghĩa là cái trần nâng từ ~1.000 lên khoảng 16.000 lượt ghi mỗi giây, và con số vẫn chính xác và có tính transaction vì nó không bao giờ rời MySQL.

sharded-counter.sqlsql
CREATE TABLE video_view_slots (
  video_id BIGINT           NOT NULL,
  slot     TINYINT UNSIGNED NOT NULL,           -- 0..15, tạo sẵn các dòng
  n        BIGINT           NOT NULL DEFAULT 0,
  PRIMARY KEY (video_id, slot)
);

-- ghi: app chọn slot = số nguyên ngẫu nhiên trong [0, 16)
UPDATE video_view_slots SET n = n + 1 WHERE video_id = 42 AND slot = ?;

-- đọc: cộng N dòng lại
SELECT SUM(n) AS views FROM video_view_slots WHERE video_id = 42;
DWG 07-CSCALE · NTSWriterchọn ngẫu nhiên 1 ô1 trong N ô, chọn ngẫu nhiên#012.9k#112.4k#213.1k#3+1#412.2k#513.0k#612.5k#712.8kSUM(n)đọc N dòng rồi cộng lạighi: ≈ N × 1.000 / giâyN row lock độc lậpđọc: N dòng, vẫn chính xác
Hình 3. Sharded counter. Lệnh ghi rơi vào một ô ngẫu nhiên và không bao giờ đụng nhau; lệnh đọc cộng mọi ô lại. Ghi rẻ được trả bằng đọc đắt hơn một chút.

Các dòng kề nhau dùng chung một page lưu trữ, nhưng page chỉ bị latch trong vài micro giây, còn row lock kéo dài tới lúc commit, nên trên thực tế các ô không xếp hàng sau nhau. Chọn N từ phép tính ở trên: lượt ghi đỉnh chia cho ~1.000, cộng thêm dư địa.

Mỗi cách chữa tốn gì

Không cách nào miễn phí, nên đây là chúng đặt cạnh nhau, với UPDATE trực tiếp làm mốc.

CáchTải lên MySQLNếu có gì chếtĐộ tươiCái giá
UPDATE trực tiếptrần ~1.000 lượt ghi/giâyChính xácTức thìKhông (cho tới khi vỡ)
Kafka + gộp batch1 UPDATE mỗi key mỗi cửa sổĐọc lại từ logTrễ vài giâyPhải vận hành cluster; consumer phải idempotent
Redis INCR + flush1 UPDATE mỗi key mỗi nhịpCó thể mất ~1 chu kỳTrễ vài giâyRẻ nhất để làm
Dòng chia nhỏN lần cái trầnChính xácTức thìĐọc N dòng; nhiều dòng hơn

Để ý điểm chung của ba cách đầu: bạn đã đổi sự chính xác ngay lúc này lấy throughput. Con số hiển thị cũ vài giây, và với Redis có thể mất vài sự kiện khi crash. Với bộ đếm lượt xem thì không ai nhận ra, và không ai nên trả tiền cho những đảm bảo mà sản phẩm không cần. Câu thần chú mình rút ra từ bài tập này đáng giữ: chọn kiến trúc là chọn trade-off hợp với yêu cầu nghiệp vụ.

Nơi tuyệt đối không được làm vậy

Giờ đọc lại phần trước với số dư ngân hàng trong đầu. Số dư cũng là “một con số mà nhiều request cùng sửa”, và cùng bài toán hot row xuất hiện với một tài khoản rất đông giao dịch. Nhưng mọi chiêu đệm ở trên đều xác nhận sự kiện trước khi nó bền và chính xác. Với số dư, đó là nói dối: một lệnh rút phải thấy giá trị hiện tại thật (nếu không tài khoản sẽ âm), và một khoản nạp mà khách đã được báo thành công phải sống sót qua crash. Lượt xem có tính giao hoán và dễ tính; tiền thì có invariant.

Scale up trước, scale out sau

Còn một phản xạ nữa bài tập này đã sửa. Khi database hụt hơi, “shard nó đi” là câu trả lời hào nhoáng, và mình đã sẵn sàng nói ra. Thứ tự tốt hơn, mà chương 1 gợi ý khi bàn về cách đối phó với tải, là: hiểu load parameter trước, rồi mới thử những nước đi nhàm chán. Service stateless thì scale out dễ; service có state thì không. Thêm app server rất tầm thường vì chúng không giữ gì. Chia dữ liệu ra nhiều database nghĩa là chọn partition key, từ bỏ transaction và join xuyên shard, và rebalance ngay giữa lúc có traffic.

  1. Sửa pattern truy cập (bài này): gộp, đệm hoặc rải hot key. Tốn một ngày, không phải một quý.
  2. Scale up: máy to hơn. Thô, và không đòi hỏi sửa ứng dụng.
  3. Read replica: phần lớn traffic là đọc, và replica sao chép cùng dữ liệu, nên không có gì bị chia cắt (nó không giúp gì cho một dòng ghi nóng, và kéo theo rắc rối về lag và failover của bài 5).
  4. Sharding: khi khối lượng ghi hoặc dung lượng dữ liệu thật sự vượt quá một node.

Cuốn sách nêu ý rằng thông lệ là giữ database trên một node cho tới khi chi phí scale hoặc yêu cầu high availability buộc phải phân tán. Hai ví dụ nổi tiếng cho thấy cả hai đầu đều chính đáng: Stack Overflow từng công khai kể họ phục vụ lượng người dùng khổng lồ từ một số ít máy rất lớn, còn Google thì không có lựa chọn nào ngoài scale out. Cả hai đều đúng, vì load parameter của họ khác nhau. Câu hỏi không bao giờ là “cái nào tốt hơn” mà là “tải của mình là gì, và tham số nào đau trước”.

Quay lại cổng quay

Chọn kiến trúc là chọn trade-off hợp với yêu cầu nghiệp vụ.

Duy Anh, ghi chú khi đọc lại DDIA

Quay lại cái link của streamer và 100.000 người kia: đám đông vẫn thế, chỉ có cái cổng quay là đổi. Nhìn lại cả bảy bài, hai câu hỏi cứ quay lại. Câu thứ nhất là load parameter của Kleppmann: số request mỗi giây, tỷ lệ đọc/ghi, hình dạng của điểm nóng. Một hệ thống không “chậm” chung chung, nó chậm với một tải cụ thể, và tải của bài này là một key nhận 100.000 lượt ghi mỗi giây. Câu thứ hai là trade-off: mỗi cách tăng tốc ở đây đều mua throughput bằng cách tiêu đi sự chính xác, độ bền hoặc sự đơn giản, và cách chọn thành thật duy nhất là biết sản phẩm có thể chịu mất thứ nào.

Nếu phải nén cả bài vào bốn dòng: hot row là một hàng đợi trá hình; một dòng bị chặn quanh 1.000 lượt ghi mỗi giây bất kể phần cứng; gộp, đệm hoặc rải các lượt ghi; và đừng bao giờ áp dụng bất kỳ cách nào với một con số phải đúng.

Hết bài

Bài viết của Vũ Duy Anh, kỹ sư backend ở Hà Nội. Câu hỏi hay góp ý sửa sai xin gửi qua email.