Tran Manh Tuan

Bài học từ cách Shopify chuyển hệ thống giữ tồn kho sang MySQL

Bối cảnh

Giữ tồn kho trông giống một bài toán đếm đơn giản, cho tới khi hệ thống phải xử lý đồng thời thanh toán, huỷ đơn, hết hạn giữ hàng và nhiều địa điểm giao hàng. Khi checkout bắt đầu, hệ thống cần giữ hàng tạm thời, rồi hoặc trừ tồn kho vĩnh viễn, hoặc trả hàng về trạng thái có thể bán.[1] Nếu các bước này lệch nhau, hệ thống có thể bán quá số lượng thật hoặc từ chối một đơn hàng vẫn còn hàng.

Shopify mới công bố cách họ thay hệ thống giữ tồn kho dùng Redis bằng MySQL. Điểm đáng học không phải là “MySQL luôn tốt hơn Redis”, mà là quyền sở hữu dữ liệu, hành vi lock và khả năng quan sát hệ thống quan trọng hơn danh tiếng về tốc độ của từng công nghệ.[1]

Shopify cho biết hệ thống đạt mức doanh số kỷ lục ở đỉnh Black Friday 2025, và mỗi checkout có hàng tồn kho đều đi qua luồng reservation.[1] Quy mô đó đủ để các kết quả có ý nghĩa thực tế thay vì chỉ là một benchmark cơ sở dữ liệu tổng hợp.

Bài gốc nói gì

Thiết kế cũ lưu reservation tạm thời trong Redis, còn inventory ledger bền vững nằm trong MySQL. Reserve và release số lượng trong Redis khá đơn giản, nhưng khi thanh toán thành công, hệ thống phải cập nhật MySQL đồng thời dọn trạng thái trong Redis. Hai thao tác thuộc hai hệ thống riêng nên không thể nằm trong cùng một ACID transaction. Nếu một nửa thất bại, reservation và ledger có thể không còn nhất quán.[1]

Shopify đưa cả hai trách nhiệm về MySQL và thay đổi mô hình dữ liệu. Thay vì một dòng quantity bị tranh chấp cho mỗi item, họ biểu diễn các đơn vị có thể giữ bằng những dòng riêng. Transaction dùng FOR UPDATE SKIP LOCKED để lấy các dòng còn trống, rồi chuyển chúng sang trạng thái reserved.[1]

SKIP LOCKED làm locking read trả kết quả ngay và bỏ qua những dòng đang bị transaction khác giữ lock. Tài liệu MySQL nói cơ chế này phù hợp với kiểu truy cập giống hàng đợi, nơi việc không nhìn thấy tạm thời một vài dòng đang khoá là chấp nhận được; nó không phải lựa chọn chung cho mọi transactional read.[2] Với inventory unit, checkout không cần đúng một dòng cụ thể, mà chỉ cần đủ số đơn vị đang có thể bán.

Nếu tạo một dòng cho mọi đơn vị hàng hoá thì bảng sẽ phình không giới hạn. Vì vậy Shopify duy trì một pool tối đa 1.000 dòng có thể reserve cho mỗi cặp item và location. Một tiến trình replenishment bổ sung pool từ ledger. Khi flash sale làm pool của một item nóng bị cạn, chỉ một transaction được phép bổ sung; các reservation còn lại chờ transaction đó thay vì cùng lúc chèn dữ liệu và tạo thundering herd.[1]

Những gì cần kiểm chứng trước khi áp dụng

Thiết kế này không chỉ là thêm một câu SQL. Bốn chi tiết sau quyết định hệ thống có chịu được tải thật hay không.

1. Hình dạng index là một phần của mô hình concurrency

Prototype đầu tiên của Shopify dùng auto-increment làm primary key. InnoDB phải lock cả entry trong secondary index dùng để tìm kiếm và record tương ứng trong clustered primary key. Khi đưa các cột truy vấn vào composite primary key, số lock quan sát được giảm từ hai xuống còn một cho mỗi dòng reservation.[1]

Quy tắc thực dụng là phải quan sát lock sinh ra từ access path thật. Một index đúng về logic vẫn có thể tạo thêm công việc lock nếu truy vấn phải đi qua secondary index rồi quay lại clustered record.

2. Isolation level thay đổi khả năng chèn dữ liệu

Khi pool rỗng, MySQL mặc định dùng REPEATABLE READ có thể tạo gap lock từ locking query và chặn chính transaction replenishment cần chèn thêm dòng. Shopify đặt riêng các transaction này ở mức READ COMMITTED.[1]

Theo tài liệu MySQL, locking read dưới READ COMMITTED chỉ lock index record thay vì cả khoảng trống phía trước, ngoại trừ kiểm tra foreign key và duplicate key.[3] Điều này tăng concurrency cho insert nhưng cũng thay đổi semantics của transaction. Tôi sẽ giới hạn isolation level này trong luồng reservation và test riêng các trường hợp pool rỗng, refill, rollback và retry.

3. Mọi luồng phải lấy lock theo cùng thứ tự

Shopify gặp deadlock khi reserve và claim chạm vào bảng unit và bảng reserved quantity theo thứ tự khác nhau. Họ sửa bằng cách chuẩn hoá thứ tự, để các transaction đồng thời không thể tạo thành vòng chờ lock.[1]

Đây phải là invariant của thiết kế, không phải bản vá một lần. Luồng cancellation, expiration hay reconciliation thêm sau này cũng phải tuân theo đúng thứ tự đó.

4. Connection có thể là tài nguyên khan hiếm, không phải CPU

Ban đầu đội ngũ tập trung vào query latency và CPU, nhưng throughput production vẫn chạm trần khi hai chỉ số này chưa bất thường. Họ gắn tag cho SQL theo business process gọi nó và đo thời gian giữ connection tại ProxySQL. Dữ liệu cho thấy những phần khác của checkout giữ connection đủ lâu để làm cạn pool dùng chung.[1]

Sau khi dọn lại toàn bộ checkout path, Shopify báo cáo giảm 50% số read và 33% số transaction trên primary database. Trong các flash sale tải cao, writer CPU vẫn dưới 50% và reader CPU dưới 16%.[1] Bài học là phải đo thời gian chiếm connection theo caller, thay vì chỉ đo thời gian từng câu query.

Cách tôi sẽ áp dụng vào production

Tôi sẽ xem pattern này như một bounded allocation queue nằm cùng database với inventory ledger:

  1. Giữ inventory ledger bền vững làm source of truth.
  2. Chỉ materialize một tập hữu hạn các unit có thể reserve cho từng item và location.
  3. Lấy đủ số unit bằng FOR UPDATE SKIP LOCKED trong một transaction ngắn.
  4. Chuyển các unit sang bảng hoặc trạng thái reserved theo một thứ tự lock cố định.
  5. Replenish từ ledger, với đúng một replenisher cho mỗi pool.
  6. Lưu thời điểm hết hạn và bảo đảm release có tính idempotent.
  7. Theo dõi lock wait, deadlock, pool depletion, transaction duration, thời gian giữ connection theo caller và sai lệch giữa ledger với reservation.

Tôi cũng sẽ học cách rollout của Shopify chứ không chỉ học schema. Họ dual-write vào Redis và MySQL ở shadow mode, vẫn giữ Redis làm source of truth trong giai đoạn so sánh, chuyển dần từng nhóm tải và giữ kill switch khi cả hai hệ thống vẫn có đầy đủ trạng thái reservation.[1] Cách này kiểm chứng correctness trên traffic thật mà không cần di chuyển toàn bộ reservation đang chạy trong một lần.

Sources

[1] https://shopify.engineering/scaling-inventory-reservations (opens in a new tab) — We replaced Redis with MySQL for inventory reservations—and it scaled [2] https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html (opens in a new tab) — MySQL 8.0 Reference Manual: Locking Reads [3] https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html (opens in a new tab) — MySQL 8.0 Reference Manual: Transaction Isolation Levels