- Hệ thống giữ chỗ tồn kho là hạ tầng cốt lõi để ngăn tình trạng bán trùng cùng một sản phẩm trong lúc xử lý thanh toán, và Shopify đã vận hành nó dựa trên Redis trong nhiều năm
- Tận dụng tính năng
SKIP LOCKED của MySQL 8, Shopify thiết kế lại từ mô hình cột số lượng trên mỗi item sang cấu trúc 1 hàng cho mỗi đơn vị bán, đạt hiệu năng cao mà không cần Redis
- Kết hợp các kỹ thuật tối ưu MySQL như khóa chính tổng hợp, mức cô lập
READ COMMITTED, thứ tự khóa nhất quán, xử lý batch bằng UNION ALL để giải quyết tranh chấp khóa và deadlock
- Nút thắt thực tế không nằm ở truy vấn giữ chỗ mà ở việc chiếm dụng connection, và sau khi đo đạc toàn bộ luồng checkout, Shopify đã giảm 50% lượt đọc DB và 33% số transaction
- Ở mức đỉnh Black Friday 2025, hệ thống xử lý doanh thu 5,1 triệu USD mỗi phút trong khi vẫn giữ writer CPU dưới 50% và reader CPU dưới 16%, vượt mục tiêu thông lượng đề ra
Bối cảnh: yêu cầu của hệ thống chống oversell
- Cần một hệ thống chống oversell (Oversell Protection) để đảm bảo tại thời điểm hoàn tất checkout, hàng tồn thực sự vẫn còn
- Reserve: khi bắt đầu thanh toán, tạm khóa item đó trong vài phút
- Claim: khi thanh toán hoàn tất, trừ vĩnh viễn số lượng khỏi sổ cái tồn kho
- Không được phép có sai sót theo cả hai hướng
- Nếu sai, có thể hai người cùng mua một sản phẩm, hoặc sản phẩm còn hàng nhưng lại bị đánh dấu hết hàng, gây thất thu
- Yêu cầu quy mô: Shopify xử lý hơn 14% thương mại điện tử tại Mỹ, và trong Black Friday 2025 đã ghi nhận doanh thu 5,1 triệu USD mỗi phút, tăng 11% so với năm trước
- Các yêu cầu cốt lõi gồm tồn kho đa địa điểm (Multi-location inventory), bảo đảm ACID, thông lượng hiệu năng cao và ưu tiên tính chính xác
Giới hạn của mô hình Redis cũ
- Trong Redis, mỗi item có một key số lượng; giữ chỗ dùng
DECR, còn giải phóng dùng INCR
- Vấn đề cốt lõi: dữ liệu giữ chỗ (Redis) và sổ cái tồn kho (MySQL) nằm ở hai hệ thống khác nhau
- Ở bước Claim, không thể gộp cập nhật MySQL và dọn dẹp Redis vào một transaction nguyên tử duy nhất
- Tùy theo thứ tự thực thi, có thể phát sinh oversell (đã bán hàng nhưng chưa trừ sổ cái) hoặc undersell (đã trừ sổ cái nhưng vẫn còn ở trạng thái giữ chỗ)
- Thiếu khả năng nhận biết tồn kho đa địa điểm, đồng thời phải gánh thêm chi phí vận hành một cụm Redis riêng
Giải pháp cốt lõi: tái thiết kế MySQL dựa trên SKIP LOCKED
Cấu trúc cơ bản: 1 hàng cho mỗi đơn vị (One Row Per Unit)
- Thay vì cột số lượng cho mỗi item, Shopify chọn cấu trúc 1 hàng cho mỗi đơn vị có thể bán
- Ví dụ: item có tồn kho 10 đơn vị → 10 hàng; khi giữ chỗ 3 đơn vị thì chọn và di chuyển 3 hàng trong một transaction duy nhất
- Đặt cả giữ chỗ và sổ cái tồn kho trong cùng một DB MySQL để xử lý reserve và claim bằng transaction ACID, loại bỏ các dạng lỗi từng xuất hiện với Redis
SKIP LOCKED: bỏ qua các hàng đang bị transaction khác khóa và trả về ngay các hàng còn khả dụng → giảm tranh chấp mà không phải chờ cùng một hàng
Giới hạn kích thước pool: tối đa 1.000 hàng mỗi địa điểm
- Giới hạn số hàng khả dụng cho mỗi tổ hợp item/địa điểm ở mức tối đa 1.000 để kiểm soát kích thước bảng và hiệu năng quét
- Ví dụ: tránh tình huống 50.000 đơn vị tồn kho × 10 địa điểm = 500.000 hàng
- Khi pool cạn, sẽ kích hoạt bổ sung nội tuyến (replenishment); đặt khóa để chỉ một transaction được phép bổ sung, tránh thundering herd khi nhiều transaction cùng chèn hàng một lúc
- Nếu pool trống hoàn toàn, chỉ reservation đó bị chậm; người mua vẫn còn hàng thật sẽ không bị báo hết hàng
4 quyết định kỹ thuật cốt lõi
1. Giảm số lượng khóa bằng khóa chính tổng hợp
- Trong nguyên mẫu ban đầu, khi dùng ID auto-increment làm khóa chính, InnoDB khóa cả secondary index và clustered index, tạo ra 2 row lock cho mỗi reservation
- Áp dụng khóa chính tổng hợp gồm
shop_id, inventory_item_id, inventory_group_id, id → vì các cột lọc nằm trong khóa chính nên số khóa giảm còn 1
- Trong môi trường có hàng nghìn reservation mỗi giây, thiết kế index và khóa chính ảnh hưởng trực tiếp đến số lượng khóa và thông lượng
2. Loại bỏ gap lock bằng READ COMMITTED
- Khi chạy
SELECT ... FOR UPDATE SKIP LOCKED trên bảng rỗng, sẽ phát sinh gap lock (bao gồm cả supremum), chặn INSERT của transaction bổ sung và gây deadlock
- Chuyển mức cô lập từ mặc định của MySQL là
REPEATABLE READ sang READ COMMITTED → thay đổi cách phát sinh gap lock, cho phép transaction bổ sung hoạt động bình thường
- Đây là lần đầu tiên codebase này dùng mức cô lập không mặc định, nên cần bổ sung hỗ trợ nhỏ trong framework để cấu hình mức cô lập theo từng transaction
3. Ngăn deadlock bằng thứ tự khóa nhất quán
- Reserve và claim truy cập hai bảng theo thứ tự khác nhau, gây deadlock
- reserve:
reserved_quantities INSERT → reservation_units DELETE
- claim:
reserved_quantities DELETE
- Cách giải quyết: chuẩn hóa để reserve luôn DELETE trên bảng units trước, rồi mới INSERT vào
reserved_quantities → loại bỏ circular wait
4. Giảm round trip bằng batch UNION ALL
- Khi giỏ hàng có nhiều line item, dùng
UNION ALL để xử lý batch các truy vấn reservation trong một round trip duy nhất
- Giảm tổng số round trip nên cải thiện độ trễ khi hệ thống có tải
Nút thắt thực tế: không phải truy vấn mà là việc chiếm dụng connection
Quá trình phát hiện vấn đề
- Trong môi trường production, hệ thống chạm trần trước khi đạt thông lượng mục tiêu; độ trễ P90 vẫn ổn, CPU chưa đạt đỉnh, truy vấn cũng đã tối ưu xong
- Các triệu chứng quan sát được trong bài test tải:
- Thread bị xếp hàng trong MySQL
- CPU tăng vọt khi các tác vụ trong hàng đợi được thực thi
- Cạn connection backend MySQL ở lớp ProxySQL
Tăng khả năng quan sát connection
- Ở lớp ứng dụng: thêm chú thích nhận diện tiến trình nghiệp vụ vào mọi câu lệnh SQL theo dạng
/* conn_tag:checkout_completion */
- Ở lớp ProxySQL: bổ sung phân tích tag và thống kê thời gian chiếm dụng connection theo từng caller
- Kết quả: có thể lập tức biết tiến trình nào đang chiếm connection trong bao lâu
Phát hiện và cách xử lý
- Ngoài reservation, các đoạn mã khác trên luồng checkout cũng giữ connection lâu hơn mức cần thiết
- Chúng không chạm ngưỡng trước nên đã bị bỏ sót khỏi danh sách cần tối ưu
- Sau khi dọn dẹp luồng checkout: giảm 50% lượt đọc từ primary DB và 33% số transaction
- Tiếp tục loại bỏ nút thắt bằng cách điều chỉnh thiết lập InnoDB thread concurrency, vốn được đặt khá bảo thủ từ nhiều năm trước và chưa từng được rà soát lại
- Sau cải tiến, ở mức flash sale lưu lượng cao: writer CPU dưới 50%, reader CPU dưới 16%
Cách chuyển đổi: Shadow Mode
- Thay vì chuyển ngay từ Redis sang MySQL, Shopify vận hành song song hai hệ thống theo Shadow Mode
- Mọi reservation đều được ghi đồng thời vào cả Redis và MySQL, trong khi Redis vẫn là source of truth
- Xác minh song song độ chính xác và hiệu năng của MySQL trên lưu lượng production thực tế
- Có thể chuyển đổi mà không cần migrate các reservation đang in-flight (vì cả hai hệ thống đều hoạt động đồng thời)
- Ngay cả sau khi chuyển source of truth sang MySQL, vẫn giữ kill switch, và nhờ đường ghi kép nên Redis luôn được cập nhật mới nhất
- Rollout được thực hiện dần dần theo từng pod, từ các pod lưu lượng thấp đến merchant có khối lượng cao nhất
Bài học rút ra
1. Hãy xem lại các quyết định cũ
- Điều không thể làm với MySQL cách đây 5 năm nay đã trở nên khả thi nhờ các tính năng mới như
SKIP LOCKED
- Các thiết lập kiểu "kinh nghiệm thực chiến" như giới hạn thread cần được rà soát lại khi workload và phần cứng thay đổi
- Nếu CPU vẫn thấp nhưng vẫn có queueing, cần đào sâu nguyên nhân
2. Bắt đầu nhỏ và quan sát
- Dựng nguyên mẫu tối thiểu bằng script Ruby nhỏ và MySQL, không cần đầy đủ framework Rails
- Việc trực tiếp quan sát hành vi khóa ở terminal thứ hai dạy được nhiều điều hơn lý thuyết
- Mẫu đo đạc chiếm dụng connection (gắn tag ở lớp ứng dụng + tổng hợp ở proxy) đơn giản để triển khai và có thể áp dụng ngay
1 bình luận
Lâu rồi mới có một bài viết đúng nghĩa phát triển thực sự như thế này.