case study Shopify: Redis to MySQL
Tung Bui Thanh (TungDaDev)16 mins read

Trong nghề phần mềm, có những quyết định đi ngược hẳn trào lưu mà cuối cùng lại thắng. Case của Shopify là ví dụ kinh điển: nhiều khi chính công nghệ "nhàm chán" (Boring Technology) mới là thứ giữ cho doanh nghiệp sống sót.
Mùa Black Friday & Cyber Monday (BFCM) năm nào cũng là kỳ "thi sát hạch" khắc nghiệt nhất với hạ tầng của Shopify. Vào giờ Flash Sale cao điểm:
- Doanh số lên tới 5.1 triệu USD mỗi phút (đỉnh của Black Friday 2025).
- Lưu lượng tính bằng hàng trăm triệu request mỗi phút: riêng BFCM 2024, Shopify công bố đỉnh 284 triệu request/phút ở tầng edge và 80 triệu ở app server.
- Hàng trăm ngàn người cùng bấm "Thanh toán ngay" trong một giây để giành 500 chiếc áo bản giới hạn.
Nhiều năm liền, công thức mà dân kỹ sư truyền tai nhau để chống bán vượt tồn kho (Overselling) trong Flash Sale là: "Dùng Redis In-Memory Counter để trừ tồn kho thật nhanh trên RAM, đừng để database quan hệ (RDBMS) đụng vào luồng này, nghẽn cổ chai ngay!".
Shopify từng làm đúng như vậy cho phần giữ chỗ (reservation): khi khách vào checkout, hệ thống tạm giữ hàng cho họ bằng một bộ đếm trong Redis. Rồi trong bài We replaced Redis with MySQL for inventory reservations—and it scaled (Shopify Engineering, 05/2026), họ kể việc chuyển toàn bộ phần giữ chỗ đó từ Redis về MySQL, và Black Friday 2025 là mùa cao điểm đầu tiên chạy trên thiết kế mới.
Vì sao lại "quay xe" như vậy? Redis hỏng ở chỗ nào khi đụng bài toán tiền thật? Và nếu về MySQL thì viết câu SQL thế nào để không nghẽn?
Bài này phân tích case study đó.
# bài toán flash sale: tốc độ và tính toàn vẹn, cái nào cũng phải giữ
Trong thương mại điện tử, bán vượt tồn kho (Overselling) là thảm hoạ, cả về pháp lý lẫn thương hiệu:
- Kho chỉ còn 5 sản phẩm.
- Hệ thống trễ một chút, bạn lỡ xác nhận đơn thành công cho 10 khách.
- Đã thu tiền của 5 người dư ra mà không có hàng giao, khách bực bội khiếu nại, ngân hàng phạt phí chargeback, thương hiệu mất uy tín.
Chiều ngược lại cũng tệ không kém: hàng còn mà hệ thống tưởng đã hết, khách bỏ đi, người bán mất doanh thu ngay giờ vàng.
# vì sao redis không trụ được ở mức hàng triệu usd/phút?
Nói cho công bằng: Redis không chậm. Redis xử lý lệnh tuần tự trên một thread, nên một lệnh DECR trên bộ đếm vốn đã nguyên tử, hai khách không thể cùng lấy đơn vị hàng cuối cùng, và mỗi lệnh chỉ tốn chưa tới một mili-giây. Vấn đề nằm ở chỗ khác. Có hai điểm yếu, và điểm thứ hai mới là lý do chính Shopify nêu ra.
# replication bất đồng bộ (asynchronous replication lag)
Đây là rủi ro chung của mọi hệ thống dùng Redis làm nguồn sự thật cho dữ liệu tiền bạc (bài của Shopify không nhắc tới điểm này). Redis là hệ thống In-Memory. Muốn mở rộng và có dự phòng thì bạn chạy mô hình Master - Replica:
- Ứng dụng gửi lệnh trừ tồn kho vào Redis Master.
- Redis Master trừ xong là trả về thành công cho Client luôn, sau đó mới gửi dữ liệu bất đồng bộ sang Redis Replica.
- Rủi ro khi Master sập (Master Failover): đúng lúc Master vừa trừ tồn kho từ 5 xuống 0 thì máy Master mất điện hoặc rớt mạng, chưa kịp đồng bộ sang Replica.
- Sentinel/Cluster tự bầu một Replica lên làm Master mới. Nhưng Replica này vẫn tưởng tồn kho là 5!
- 5 người khác mua tiếp thành công, Overselling xảy ra ngay!
Lệnh WAIT của Redis giảm được rủi ro này nhưng không biến Redis thành hệ thống nhất quán mạnh; tài liệu của Redis cũng nói rõ là ghi đã được xác nhận vẫn có thể mất khi failover.
# thiếu acid transaction xuyên hệ thống
Đây là vấn đề Shopify gặp thật. Hệ thống cũ có hai nguồn sự thật: Redis giữ số lượng khách đang tạm giữ (giữ chỗ là DECR, nhả là INCR), còn MySQL giữ sổ cái tồn kho thật của người bán. Một giao dịch mua hàng lại không chỉ có trừ tồn kho:
- Trừ tồn kho.
- Tạo bản ghi đơn hàng (Order record).
- Tạo giao dịch trừ tiền (Payment charge).
- Tạo lịch sử vận chuyển (Fulfillment reserve).
Khi checkout hoàn tất, hệ thống phải ghi vào sổ cái MySQL và xoá phần giữ chỗ trong Redis. Không có transaction nào bọc được cả hai. Máy chết giữa hai lần ghi thì hai bên lệch nhau: hoặc hàng còn mà bị giấu đi (vẫn bị tính là đang giữ chỗ), hoặc hàng đã bán rồi mà vẫn mở bán tiếp. Đổi thứ tự ghi cũng chỉ đổi kiểu lỗi nào có thể xảy ra, chứ không xoá được khoảng hở giữa hai lần ghi.
Muốn vá thì phải viết cơ chế bù trừ (Compensating Transaction) và job đối soát chạy nền để cộng trả tồn kho. Ở quy mô hàng triệu checkout, giữ Redis và MySQL khớp nhau là một cơn ác mộng phân tán (Distributed State Reconciliation). Đưa giữ chỗ về cùng database với sổ cái thì cả hai thao tác nằm gọn trong một transaction, khoảng hở biến mất.
# lá bài của mysql: atomic conditional update & row-level locking
Về MySQL (InnoDB) rồi, cách đầu tiên ai cũng nghĩ tới là giữ một dòng cho mỗi mặt hàng, và trừ số lượng bằng một câu UPDATE có điều kiện:
-- Câu lệnh ma thuật chống Overselling tuyệt đối:
UPDATE inventory_items
SET available = available - 1
WHERE id = 12345
AND available >= 1;# câu lệnh này đúng, nhưng chưa đủ cho flash sale
Về tính đúng thì câu lệnh trên không chê được:
- Nguyên tử (Atomic): InnoDB đặt khoá độc quyền trên đúng dòng
id = 12345, đọc giá trị mới nhất, kiểm tra điều kiện rồi mới ghi. Hai request không thể cùng thấyavailable = 1rồi cùng trừ. - Không bao giờ Oversell: nếu
available = 0thì điều kiệnavailable >= 1không thoả, câu lệnh trả vềRows Affected = 0. Code Java chỉ cần kiểm tra:int rowsUpdated = jdbcTemplate.update(UPDATE_INVENTORY_SQL, itemId); if (rowsUpdated == 0) { throw new OutOfStockException("Sản phẩm đã hết hàng!"); } - Nằm gọn trong cùng một Transaction ACID: lệnh trừ tồn kho chung một Database Transaction với bản ghi đơn hàng và lịch sử thanh toán. Một bước fail thì rollback hết, tồn kho tự về như cũ, không cần viết dòng code bù trừ nào.
- Bền vững (Durability): với cấu hình mặc định (
innodb_flush_log_at_trx_commit=1,sync_binlog=1), redo log đượcfsyncxuống đĩa trước khi báo commit thành công, nên máy có mất điện đột ngột thì dữ liệu đã commit vẫn còn.
Chỗ vướng là khoá dòng được giữ tới khi transaction commit, không phải nhả ngay sau câu UPDATE. Nếu UPDATE nằm chung transaction với việc tạo đơn hàng như ý 3, mọi checkout của cùng một mặt hàng phải xếp hàng một hàng dọc sau đúng một dòng. Flash sale dồn hàng ngàn người vào một sản phẩm, dòng đó thành hot row, request chờ khoá dồn ứ, giữ connection lâu, và độ trễ lan sang cả những phần khác của hệ thống. Theo bài viết của Shopify, các lần thử trước với mô hình một dòng có cột số lượng đều thất bại vì đúng lý do này.
Lời giải của họ là đảo mô hình: thay vì một dòng chứa số lượng, mỗi đơn vị hàng có thể giữ chỗ là một dòng. Giữ chỗ một đơn vị nghĩa là khoá và xoá một dòng bất kỳ còn trống, và SKIP LOCKED (có từ MySQL 8.0) cho phép bỏ qua những dòng đang bị transaction khác khoá thay vì đứng chờ. Hai khách mua cùng một món sẽ lấy hai dòng khác nhau và commit song song.
Đoạn SQL dưới đây là phác thảo của mình theo mô tả trong bài, không phải schema thật của Shopify:
-- Mỗi dòng là một đơn vị hàng đang sẵn sàng để giữ chỗ
CREATE TABLE reservation_units (
shop_id BIGINT NOT NULL,
inventory_item_id BIGINT NOT NULL,
id BIGINT NOT NULL,
PRIMARY KEY (shop_id, inventory_item_id, id)
);
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- Lấy một đơn vị chưa ai khoá; dòng đang bị người khác giữ thì bỏ qua, không chờ
SELECT id FROM reservation_units
WHERE shop_id = 1 AND inventory_item_id = 12345
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- Không có dòng nào: pool đã cạn -> bổ sung pool rồi thử lại, hoặc báo hết hàng
DELETE FROM reservation_units
WHERE shop_id = 1 AND inventory_item_id = 12345 AND id = 1;
INSERT INTO reserved_quantities (checkout_id, shop_id, inventory_item_id, quantity)
VALUES (987, 1, 12345, 1);
COMMIT;Thử mở hai session: session A khoá một dòng và chưa commit, session B chạy cùng câu SELECT ... FOR UPDATE SKIP LOCKED sẽ nhận ngay một dòng khác thay vì đứng chờ A. Bỏ SKIP LOCKED đi thì B phải đợi A commit.
Tạo sẵn một dòng cho mỗi đơn vị hàng nghe thì tốn, nên Shopify giới hạn pool tối đa 1.000 dòng cho mỗi cặp mặt hàng và địa điểm kho. Một tiến trình nền bổ sung pool khi nó vơi đi; nếu pool cạn giữa flash sale thì luồng giữ chỗ tự bổ sung ngay rồi thử lại, có khoá để hàng loạt request không cùng lao vào bổ sung một lúc. Con số 1.000 được chọn theo tốc độ giữ chỗ cao nhất họ quan sát được: đủ lớn để không cạn khi có đợt dồn, đủ nhỏ để bảng gọn và lượt quét SKIP LOCKED vẫn nhanh.
TIP
Atomic Update vs Pessimistic Lock: với bộ đếm bình thường, đừng SELECT ... FOR UPDATE rồi tính toán ở ứng dụng và UPDATE sau; cách đó tốn thêm một vòng gọi DB mà vẫn giữ khoá suốt thời gian đó. Câu UPDATE ... SET count = count - 1 WHERE id = ? AND count >= 1 gọn hơn và vẫn nguyên tử. Nhưng khi hàng ngàn request cùng nhắm vào một dòng thì cả hai cách đều phải xếp hàng sau một khoá. Lúc đó cần chia tranh chấp ra nhiều dòng, như mô hình một dòng mỗi đơn vị kết hợp FOR UPDATE SKIP LOCKED ở trên.
# mysql gánh 5.1 triệu usd/phút kiểu gì?
Câu hỏi đặt ra ngay: "Ai cũng đập vào MySQL thì Database chẳng chết vì nghẽn CPU với Disk I/O à?".
Thiết kế bảng chỉ là một nửa câu chuyện. Nửa còn lại nằm ở cách Shopify chia dữ liệu, quản lý connection và tinh chỉnh InnoDB:
# sharding thành pods
Shopify không dùng một máy MySQL khổng lồ. Hệ thống chính của họ chia thành hàng trăm pod: mỗi pod là một bản Shopify gần như độc lập, có shard MySQL riêng cùng Redis, Memcached riêng, và chứa một nhóm cửa hàng. shop_id là khoá sharding, request của cửa hàng nào được định tuyến về pod của cửa hàng đó (xem bài về shard balancing của Shopify).
- Mỗi cửa hàng (Merchant) nằm trọn trong một pod, và một pod chứa nhiều cửa hàng.
- Khi hàng trăm ngàn người tranh mua đồ của một hãng thời trang, tải dồn vào pod của hãng đó; các pod khác không bị ảnh hưởng. Những cửa hàng ở chung pod thì vẫn chịu chung, nên Shopify có công cụ dời cửa hàng giữa các shard để cân tải.
Việc chuyển sang MySQL cũng được triển khai theo pod: chạy song song ở chế độ "shadow" (Redis vẫn là nguồn sự thật, MySQL chạy kèm để so kết quả), rồi bật dần từ pod ít tải tới pod nhiều tải.
Còn Vitess (công nghệ sharding MySQL do YouTube làm ra) thì Shopify dùng cho backend của Shop app, không phải phần lõi này, và bài về inventory không nhắc tới nó.
# connection pooling tối ưu (proxysql)
Shopify dùng ProxySQL đứng giữa ứng dụng và MySQL để gộp (multiplexing) rất nhiều kết nối từ tầng ứng dụng vào một nhóm nhỏ kết nối vật lý mở sẵn tới MySQL.
Phát hiện đáng giá nhất của dự án nằm ở đây. Sau một loạt tối ưu, thông lượng chững lại dù latency vẫn ổn và CPU còn dư. Họ gắn nhãn cho từng câu SQL theo nghiệp vụ gọi nó (kiểu comment /* conn_tag:checkout_completion */), rồi đo ở ProxySQL xem mỗi nghiệp vụ giữ connection bao lâu. Hoá ra nút thắt không nằm ở phần giữ chỗ mà ở những đoạn khác của luồng checkout giữ connection lâu hơn cần thiết. Dọn dẹp xong, họ bỏ được 50% số lượt đọc và 33% số transaction trên database primary. Họ cũng phát hiện tham số về số thread InnoDB chạy đồng thời đã được đặt rất dè dặt từ nhiều năm trước và chưa ai xem lại.
# tinh chỉnh innodb: khoá, isolation level và thứ tự lock
Bài của Shopify không nói gì về phần cứng lưu trữ. Ổ NVMe nhanh giúp fsync redo log nhanh hơn, nhưng không gỡ được hot row; còn đặt dữ liệu MySQL lên RAM disk thì mất luôn tính bền vững vừa nói ở trên. Những gì thực sự giúp họ đạt mục tiêu là các chỉnh sửa ở tầng khoá:
- Primary key ghép: đổi khoá chính từ id tự tăng sang
(shop_id, inventory_item_id, inventory_group_id, id). InnoDB lưu dữ liệu theo thứ tự khoá chính, nên câu tìm đơn vị còn trống quét thẳng trên khoá chính, không đi vòng qua index phụ. Mỗi lần giữ chỗ từ hai khoá dòng còn một. - Hạ isolation xuống READ COMMITTED: ở mức mặc định
REPEATABLE READ, InnoDB đặt thêm gap lock (khoá cả khoảng trống giữa các dòng). Khi pool rỗng, gap lock chặn chính transaction bổ sung pool chèn dòng mới, thậm chí gây deadlock.READ COMMITTEDbỏ phần lớn gap lock này. - Thống nhất thứ tự lấy khoá: mọi luồng đều xoá ở bảng đơn vị trước rồi mới ghi bảng số lượng đã giữ, để hai transaction không chờ vòng tròn lẫn nhau.
- Gộp truy vấn: giỏ hàng nhiều sản phẩm dùng
UNION ALLđể lấy đơn vị cho nhiều mặt hàng trong một lần gọi DB.
Kết quả theo Shopify: trong các đợt flash sale lớn, CPU của node ghi giữ dưới 50%, node đọc dưới 16%.
# so sánh kiến trúc: redis vs mysql cho bài toán tồn kho
| Tiêu chí | Mô hình Redis Counter | Mô hình MySQL row-per-unit (Shopify) |
|---|---|---|
| Tính toàn vẹn (Integrity) | Lệch với sổ cái MySQL nếu crash giữa hai lần ghi | Giữ chỗ và sổ cái chung một transaction |
| Quản lý Transaction | Phức tạp (cần Saga, bù trừ phân tán) | Tự nhiên, gọn trong 1 Transaction |
| Độ trễ (Latency) | Cực thấp (< 1ms) | Cao hơn (round-trip, khoá, commit xuống đĩa) |
| Phục hồi sau sự cố | Rất khó đối soát dữ liệu | Dễ nhờ nhật ký giao dịch (Binlog) |
| Chi phí vận hành | Đắt (phải nuôi 2 hệ thống song song) | Gọn, tận dụng hạ tầng sẵn có |
# bài học cho dân làm phần mềm
Case study của Shopify để lại vài bài học đáng nhớ khi thiết kế hệ thống:
- Đừng vội đưa NoSQL / In-memory vào chỉ vì nghe có vẻ nhanh: Redis không sai ở tốc độ; cái giá nằm ở chỗ có thêm một nguồn sự thật phải giữ cho khớp. Nhanh mà dữ liệu không toàn vẹn thì cũng chỉ là làm sai nhanh hơn thôi!
- Hiểu thật kỹ công cụ mình đang có: RDBMS như MySQL hay PostgreSQL đã khoảng 30 năm phát triển. Khoá dòng,
SKIP LOCKED, isolation level là những công cụ có sẵn, nhưng phải hiểu khoá được giữ bao lâu và khoá cái gì thì mới dùng đúng. - Đo trước khi đoán: nút thắt cuối cùng của Shopify không nằm ở câu SQL giữ chỗ mà ở thời gian giữ connection của những đoạn code khác. Gắn nhãn cho truy vấn để biết ai đang chiếm tài nguyên đáng giá hơn nhiều vòng tối ưu mò mẫm.
- Chọn công nghệ nhàm chán (Choose Boring Technology): khi bài toán dính trực tiếp tới tiền và sự sống còn của doanh nghiệp, hãy chọn giải pháp đã được thời gian kiểm chứng, dễ debug, dễ sao lưu và giữ được nguồn sự thật duy nhất (Single Source of Truth).
Muốn đọc bản gốc với đầy đủ chi tiết thì xem bài của Shopify Engineering.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.