Nghệ Thuật UPSERT: Đòn Đánh "Một Kích Tất Sát" Khắc Chế Race Condition
Ở bài trước, chúng ta đã học cách dùng Idempotency để chống lại việc VNPay dội bom Webhook 2 lần liên tiếp. Nhưng đời không như mơ. Chuyện gì sẽ xảy ra nếu VNPay không gửi 2 cái Webhook cách nhau 1 phút, mà nó bị lỗi mạng và gửi 2 cái Webhook ĐỒNG THỜI trong cùng 1 mili-giây?
Lúc này, cái đoạn code kiểm tra "Nếu đơn hàng chưa có thì Thêm mới, nếu có rồi thì Cập nhật" của bạn sẽ trở thành một mớ hỗn độn và Database sẽ văng lỗi đỏ chót.
Để giải quyết bài toán này, các hệ quản trị CSDL (MySQL, PostgreSQL) đã tạo ra một "Vũ khí hạng nặng" gom 2 hành động đó làm 1. Chào mừng bạn đến với kỹ thuật UPSERT (Update or Insert)!
PHẦN 1: TƯ DUY "THỢ GÕ" VÀ THẢM HỌA CHECK-THEN-ACT
Hãy tưởng tượng bạn làm tính năng Đồng bộ thông tin User từ Google về hệ thống. Logic bề mặt rất đơn giản:
- Nếu Email này chưa có trong Database ->
INSERT(Tạo mới). - Nếu Email này đã có ->
UPDATE(Cập nhật tên và avatar mới nhất).
Cách code kinh điển của 90% anh em mới vào nghề:
// MÔ HÌNH: CHECK-THEN-ACT (Kiểm tra rồi mới Hành động)
const user = await db.query('SELECT * FROM users WHERE email = ?', ['hieu@vibe.com']);
if (!user) {
// Chưa có thì tạo mới
await db.query('INSERT INTO users (email, name) VALUES (?, ?)', ['hieu@vibe.com', 'Hiếu']);
} else {
// Có rồi thì cập nhật
await db.query('UPDATE users SET name = ? WHERE email = ?', ['Hiếu Vibe', 'hieu@vibe.com']);
}
Đoạn code này nhìn rất hợp lý, nhưng nó chứa 2 TỬ HUYỆT
1. Chậm chạp (2 Network Roundtrips): Bạn tốn 1 lần đi đường mạng xuống DB để hỏi (SELECT), rồi lại tốn thêm 1 lần đi đường mạng để ra lệnh (INSERT/UPDATE). Băng thông bị lãng phí gấp đôi.
2. Thảm họa Race Condition (Điều kiện tương tranh): Điều gì xảy ra nếu User bấm nút "Đồng bộ" 2 lần liên tiếp cực nhanh (hoặc do lỗi cáp quang gửi 2 request cùng lúc)?
T=0ms: Luồng (Thread) 1 chạy lệnhSELECT. Thấyuserlà Null.T=1ms: Luồng (Thread) 2 cũng chạy lệnhSELECT. Nó cũng thấyuserlà Null (vì Luồng 1 chưa kịp tạo!).T=2ms: Luồng 1 chạy lệnhINSERTthành công.T=3ms: Luồng 2 cũng chạy lệnhINSERT(vì ở T=1ms nó tưởng là chưa có). BÙM! Lỗi CSDL văng ra:Duplicate entry 'hieu@vibe.com' for key 'users.email'. App của bạn sập!
PHẦN 2: UPSERT - SỨC MẠNH CỦA SỰ NGUYÊN TỬ (ATOMICITY)
Một Vibe Coder không bao giờ tự mình giải quyết chuyện kiểm tra đồng thời ở tầng Node.js/PHP. Họ đẩy gánh nặng đó xuống cho kẻ kiểm soát dữ liệu quyền lực nhất: Database Engine.
Chúng ta sẽ dùng cú pháp UPSERT (Ghép của chữ UPDATE và INSERT).
Đặc tính của câu lệnh này là Atomic (Nguyên tử). Nghĩa là Database sẽ khóa chặt dòng dữ liệu đó lại, tự động kiểm tra và thực thi trong đúng 1 nhịp duy nhất. Không một luồng nào khác có thể chen ngang.
Cú pháp trên MySQL: ON DUPLICATE KEY UPDATE
Để UPSERT hoạt động, Cột bạn dùng để làm mốc (ở đây là email) bắt buộc phải được đánh Index là UNIQUE (Duy nhất) hoặc PRIMARY KEY.
Nếu bạn cố INSERT một Email đã tồn tại, MySQL sẽ bắt được lỗi Unique đó, và thay vì văng lỗi làm sập App, nó sẽ tự động bẻ lái sang lệnh UPDATE.
INSERT INTO users (email, name, login_count)
VALUES ('hieu@vibe.com', 'Hiếu Vibe', 1)
-- NẾU XẢY RA ĐỤNG ĐỘ UNIQUE KEY (Tức là Email đã tồn tại)
ON DUPLICATE KEY UPDATE
name = VALUES(name), -- Cập nhật tên mới nhất
login_count = login_count + 1; -- Cộng dồn số lần đăng nhập
Tại sao nó là kiệt tác?
- Chỉ tốn đúng 1 câu query: Node.js bắn 1 lệnh xuống DB, xong việc. Tiết kiệm 50% thời gian trễ mạng.
- Miễn nhiễm với Race Condition: Kể cả 10.000 luồng bắn lệnh này cùng một mili-giây, Engine C/C++ của MySQL sẽ xếp hàng chúng lại ở mức độ lõi, thằng đầu tiên sẽ
INSERTthành công, 9.999 thằng phía sau sẽ tự động chuyển thànhUPDATE(cộng login_count lên 10.000). Không bao giờ có lỗi!
Cú pháp trên PostgreSQL: ON CONFLICT DO UPDATE Nếu bạn xài Postgres, cú pháp của nó mang tính "kỹ thuật" và rõ ràng hơn một chút:
INSERT INTO users (email, name, login_count)
VALUES ('hieu@vibe.com', 'Hiếu Vibe', 1)
-- CHỈ ĐỊNH RÕ RÀNG: Nếu đụng độ ở cột 'email'
ON CONFLICT (email)
DO UPDATE SET
name = EXCLUDED.name,
login_count = users.login_count + 1;
PHẦN 3: KHI NÀO KHÔNG NÊN DÙNG UPSERT?
UPSERT quá tuyệt vời, nhưng không phải là liều thuốc bách bệnh. Bạn KHÔNG NÊN dùng UPSERT nếu:
1. Bảng của bạn sử dụng Auto Increment ID quá lớn và bạn quan tâm đến việc rỗng số: Trong MySQL (InnoDB), khi một lệnh UPSERT được gọi, MySQL sẽ "chuẩn bị sẵn" một cái ID tự tăng (Auto Increment) cho thao tác Insert. Kể cả khi lệnh đó cuối cùng biến thành UPDATE (không có dòng mới nào được tạo), cái ID vừa chuẩn bị đó cũng sẽ bị vứt bỏ. Nếu bạn UPSERT 1 triệu lần vào một dòng có sẵn, ID tự tăng của bạn sẽ bị "nhảy cóc" mất 1 triệu số!
2. Logic UPDATE quá phức tạp:
Nếu sau khi kiểm tra User đã tồn tại, bạn cần gọi API bên thứ 3, bắn Event gửi Email, hoặc kiểm tra quyền phân quyền phức tạp ở tầng Node.js rồi mới quyết định xem có UPDATE hay không... thì UPSERT bó tay. Lúc này bạn buộc phải dùng cơ chế Mutex Lock (như đã học ở bài Redis) để khóa luồng lại và xử lý logic Check-Then-Act an toàn.
Lời kết
Việc phát hiện ra những "điểm mù" của thời gian (mili-giây) là kỹ năng phân định giữa người viết code cho chạy được và người thiết kế hệ thống có khả năng chịu tải cao. Sử dụng UPSERT giúp bạn tiết kiệm hàng rổ code loằng ngoằng, gỡ bỏ gánh nặng cho Network, và quan trọng nhất: Ép Database phải bảo vệ tính toàn vẹn dữ liệu cho bạn!
Chủ đề tiếp theo: Sự Thật Về Index - Khi Cây B-Tree Bị Lạm Dụng
Mình đã nhắc đến việc Cột Email phải có UNIQUE INDEX thì UPSERT mới hoạt động. Vậy Index (Chỉ mục) là gì?
Nhiều anh em cứ thấy câu Query nào chạy chậm là nhắm mắt nhắm mũi thêm chữ INDEX vào cột đó. Họ không biết rằng, mỗi khi thêm Index, bạn đang đánh đổi tốc độ Đọc (SELECT) lấy tốc độ Ghi (INSERT/UPDATE), và tự đắp thêm hàng Gigabyte rác vào ổ cứng.
Tại sao dùng hàm LIKE '%...%' thì Index bị vô hiệu hóa? Sự khác biệt giữa B-Tree Index và Hash Index là gì? Ở bài viết tới, chúng ta sẽ xẻ dọc Engine của Database để hiểu cách Index vận hành. Đây là bài toán sống còn để tối ưu CSDL! Anh em đón đọc nhé!
All rights reserved