Skip to main content

Vì Sao Shopify Chuyển Hệ Thống Reservation Tồn Kho Từ Redis Sang MySQL

Cập nhật: 2026-08-20

Hai người mua cùng nhấn nút đặt hàng cho chiếc hoodie xanh cuối cùng, cách nhau chưa đầy một giây. Ở quy mô hàng triệu giao dịch mỗi ngày, hệ thống của Shopify phải phân xử chuyện đó chính xác tuyệt đối — ai được hàng, ai phải nhận một email xin lỗi. Bài viết này kể lại cách Shopify Engineering giải bài toán đó: họ thay Redis bằng MySQL trong hệ thống reservation (giữ chỗ) tồn kho. Điều thú vị là hướng đi này ngược lại với xu hướng phổ biến — đa số team bắt đầu với MySQL/Postgres, thấy không scale nổi rồi mới tách phần đó ra Redis. Shopify lại đi theo chiều ngược lại, và đó là bằng chứng cho thấy MySQL (hay Postgres) có thể xử lý scale lớn đến mức nào.

Mục lục


1. Bài toán: đảm bảo chính xác tồn kho khi checkout

Khi một buyer mua hàng trên Shopify, hệ thống phải đảm bảo món hàng đó thực sự còn để bán. Nếu sai, có hai kịch bản lỗi:

  • Oversell: hai buyer cùng nghĩ rằng mình vừa mua được đơn vị cuối cùng. Một trong hai người sẽ phải bị hủy đơn, hoàn tiền vào thẻ, kèm theo một email xin lỗi.
  • Undersell: một buyer bị báo là hàng đã hết, nhưng thực ra vẫn còn. Merchant mất doanh thu vì lẽ ra có thể bán được món hàng đó, nhưng Shopify lại báo sai là hết hàng.

Ở quy mô của Shopify, những lỗi này dù hiếm cũng có thể dồn lại rất nhanh.

2. Giải pháp sản phẩm: Reservation system

Giải pháp sản phẩm là hệ thống reservation quen thuộc gắn với transaction và checkout: khi cho một món hàng vào giỏ, Shopify sẽ giữ (reserve) món đó lại cho người dùng trong một khoảng thời gian, trong lúc họ nhập thông tin thẻ và thực hiện thanh toán.

  • Nếu thanh toán thành công → đơn vị đó bị trừ khỏi tồn kho của merchant, hàng chính thức thuộc về buyer.
  • Nếu thanh toán thất bại, hoặc hết thời gian giữ chỗ → đơn vị đó quay lại pool để người khác có thể giữ chỗ tiếp.

💡 Hình dung như thế này: reservation giống việc giữ bàn ở nhà hàng qua điện thoại. Nhà hàng không xóa bàn đó khỏi sơ đồ, chỉ tạm đánh dấu "đã có người giữ" trong một khoảng thời gian nhất định. Khách đến đúng giờ, bàn chính thức thuộc về họ. Khách không tới sau thời gian chờ, nhà hàng gỡ dấu giữ chỗ và bàn đó lại sẵn sàng cho khách khác.

3. Kiến trúc cũ dùng Redis: cấu trúc dữ liệu và xử lý hết hạn

Trong nhiều năm, Shopify dùng Redis cho phần này. Mỗi Redis key đại diện cho một loại hàng tồn kho cụ thể (ví dụ một chiếc "blue hoodie"), và value của key là số lượng hiện còn có thể bán. Khi một user cần giữ chỗ một item, count đó bị giảm (decrement); nếu họ không hoàn tất mua hàng, count đó phải được tăng (increment) trở lại. MySQL luôn là nguồn sự thật (source of truth) trong suốt quá trình này.

Ví dụ: có 10 hoodie, cả Redis và MySQL đều đồng ý là 10. Một user checkout, hoodie vào giỏ trigger một reservation, Redis giảm xuống 9 nhưng MySQL vẫn giữ 10. Khi user hoàn tất thanh toán, MySQL cũng giảm xuống 9, hai bên lại đồng bộ.

Xử lý reservation hết hạn

Điều mà blog gốc của Shopify không đề cập chi tiết là cách xử lý khi user không hoàn tất thanh toán (hết thời gian, đóng laptop, v.v.). Phần này chỉ là suy đoán riêng của tác giả bài phân tích dựa trên cách các hệ thống tương tự thường vận hành, không phải nội dung xác nhận từ blog Shopify:

  • Alongside count, Redis khả năng cao còn giữ một sorted set — về cơ bản là một heap. Mỗi khi có reservation, ngoài việc giảm count, hệ thống thêm reservation đó vào sorted set với score là thời điểm hết hạn.
  • Nhờ vậy, phần tử trên đỉnh sorted set luôn là reservation sắp hết hạn tiếp theo. Một cron job có thể lấy phần tử đầu, kiểm tra đã hết hạn chưa, nếu đúng thì gỡ khỏi heap và tăng count trở lại.

Cron job này hoạt động tốt trong đa số trường hợp, nhưng nếu một món hàng bán rất nhanh, việc chờ cron job (dù chạy mỗi 10 giây) vẫn tạo ra độ trễ — user có thể bị báo hết hàng trong khi thực chất một đơn vị đã đáng lẽ được trả lại pool.

Cách khắc phục là gộp việc reclaim và reserve vào một transaction atomic: trước khi lấy một đơn vị từ pool, hệ thống kiểm tra đỉnh heap xem có reservation nào đã hết hạn không, gỡ và tăng count lại, rồi mới thực hiện decrement của chính mình. Toàn bộ chuỗi thao tác này có thể gói trong một Lua script của Redis, chạy atomic giống như một transaction trong database. Với cách này, không bao giờ có chuyện từ chối một buyer chỉ vì một reservation đã chết nhưng chưa được dọn; việc dọn dẹp xảy ra ngay tại thời điểm có người cần đến, còn cron job chỉ đóng vai trò backstop dọn dẹp định kỳ ở background.

4. Vấn đề: hai hệ thống phải đồng bộ, hai lần ghi không atomic

Thiết kế dùng Redis là hợp lý: lý do chọn Redis ngay từ đầu là nó xử lý được throughput đọc/ghi rất cao trên một key duy nhất. Nếu count đó chỉ là một row trong MySQL, mọi lượt checkout cho item đó sẽ tranh chấp cùng một row, và ở quy mô lớn, đây chính là điểm khiến hệ thống chậm lại.

Nhưng vấn đề nó tạo ra — và cũng là động lực để Shopify tiến hoá sang hệ thống mới — là giờ có hai hệ thống phải giữ đồng bộ: Redis và MySQL. Khi thanh toán thành công, tồn kho bị trừ ở MySQL, nhưng đồng thời cũng phải gỡ reservation đó khỏi heap trong Redis (nếu để nguyên, nó sẽ hết hạn và bị cộng ngược lại pool dù đã bán). Đó là hai lần ghi trên hai hệ thống khác nhau, không có cách nào gói chung vào một bước atomic. Nếu có crash hoặc lỗi xảy ra giữa hai thao tác đó, hệ thống có thể rơi vào trạng thái không nhất quán, hai bên bất đồng về số lượng có thể bán.

💡 Hình dung như thế này: giống việc chuyển tiền giữa hai cuốn sổ kế toán riêng biệt bằng tay — sổ A trừ tiền, sổ B cộng tiền. Nếu có ai đó ngắt ngang đúng lúc sổ A vừa ghi xong nhưng sổ B chưa kịp ghi, hai cuốn sổ sẽ lệch nhau mãi mãi, vì không có cách nào "cuộn lại cả hai cùng lúc" khi chúng là hai cuốn sổ tách biệt.

Payment Success — Two Non-Atomic WritesPayment SucceededWrite 1Write 2MySQLdeduct inventory ✓Redisremove reservation ✗crash / lỗi ở đây → không có cách rollback cả hai cùng lúcInconsistent stateMySQL đã bán, nhưng Redis vẫn coi reservation còn sống —hết hạn sẽ cộng ngược lại pool dù hàng đã bán mất

Vậy nếu gom cả reservation và inventory về lại một chỗ, cùng một database, cùng một transaction, thì sao?

5. Nỗ lực chuyển sang MySQL: từ single row đến N rows riêng biệt

Trong một thế giới lý tưởng, mọi thứ nên nằm trong một data store duy nhất, cùng một transaction. Đây chính xác là điều Shopify từng thử trước đó. Blog viết khá ngắn gọn về phần này, chỉ nói rằng những nỗ lực ban đầu đã thất bại, vì một row đơn với một cột quantity không chịu được mức độ tranh chấp (contention).

Hình dung: trong MySQL, họ có một row cho reservation count và một row cho tổng tồn kho — tức là bê nguyên những gì Redis đang làm sang MySQL. Mỗi lượt checkout cho hoodie đó là một update cạnh tranh cùng một row, và MySQL chỉ cấp row lock cho một transaction tại một thời điểm — buyer xếp hàng một hàng dọc, chờ người phía trước commit xong.

Bước tiếp theo tự nhiên là chia row đó (giả sử quantity = 10) thành 10 rows riêng biệt. Về logic, đây là cách hợp lý vì nó chia nhỏ đối tượng đang bị tranh chấp — giờ không phải ai cũng tranh một row nữa. Nhưng vấn đề nằm ở cách viết query: dùng SELECT ... FOR UPDATE để tự lock row, nhưng mỗi query vẫn luôn lấy row đầu tiên còn trống. Buyer thứ hai xếp hàng phía sau buyer đầu, buyer thứ ba xếp sau họ — vẫn chưa giải quyết được gì, dù có nhiều row khác đang trống mà các buyer không biết để "nhắm" vào.

Vậy làm sao để mỗi buyer tự tìm được một row còn trống, thay vì tất cả cùng xếp hàng chờ đúng một row duy nhất?

6. Bước đột phá: SKIP LOCKED (MySQL 8, 2018)

Điểm đột phá — một trong những điều thú vị nhất trong blog của Shopify — là MySQL 8 ra mắt tính năng SKIP LOCKED, chính thức có mặt trong bản GA MySQL 8.0 năm 2018. Khi query đang quét tìm một row để lock mà gặp row đã bị người khác lock, thay vì đứng chờ transaction kia hoàn tất như trước, nó bước qua và tiếp tục tìm row trống tiếp theo. Nhờ vậy, hai buyer cùng nhắm vào một loại hoodie giờ có thể rơi vào hai row khác nhau và cùng commit đồng thời.

💡 Hình dung như thế này: giống việc xếp hàng ở dãy quầy thu ngân. Không có SKIP LOCKED giống như mọi khách chỉ được phép đứng vào đúng một quầy cố định — nếu quầy đó đang phục vụ người khác, khách phải đứng chờ, dù các quầy bên cạnh đang trống. Có SKIP LOCKED giống việc mỗi khách tự nhìn quanh, thấy quầy nào đang bận thì bỏ qua ngay, chọn quầy trống kế tiếp để vào.

Flow mới, ví dụ với 5 hoodie: thay vì một row mang giá trị 5, giờ có 5 rows riêng trong bảng reservation, mỗi row cho một hoodie.

  1. Buyer mở transaction, chạy SELECT ... FOR UPDATE SKIP LOCKED để lấy một row còn trống.
  2. Ghi một hold nói rằng đơn vị đó thuộc về họ tại checkout, rồi commit. Giờ chỉ còn 4 row trống.
  3. Nếu thanh toán thành công: trừ hoodie đó khỏi tồn kho merchant và retire hold — cả hai việc này nằm trong cùng một transaction, cùng một database.
  4. Nếu thanh toán thất bại: xóa hold, đưa row trở lại pool, quay về 5 row trống.

Minh họa cụ thể hơn bằng SQL: giả sử bảng reservation_units có 5 row cho một hoodie, và Buyer A đang giữ transaction mở trên row đầu tiên (chưa commit).

-- Session 1 (Buyer A) — mở transaction, lock row đầu tiên còn trống
BEGIN;
SELECT id FROM reservation_units
WHERE item_id = 'blue-hoodie' AND status = 'free'
LIMIT 1 FOR UPDATE;
-- Output minh họa
-- id
-- ---
-- 101
-- (chưa COMMIT — row 101 đang bị Session 1 giữ lock)
-- Session 2 (Buyer B) — chạy song song, trong lúc Session 1 chưa commit
SELECT id FROM reservation_units
WHERE item_id = 'blue-hoodie' AND status = 'free'
LIMIT 1 FOR UPDATE SKIP LOCKED;
-- Output minh họa
-- id
-- ---
-- 102
-- (trả về ngay lập tức — bỏ qua row 101 đang bị khóa, lấy row 102 còn trống)

Nếu Session 2 không có SKIP LOCKED, nó sẽ đứng chờ (block) cho tới khi Session 1 COMMIT hoặc ROLLBACK, dù row 102–105 đang hoàn toàn trống. (Output minh họa — cú pháp và hành vi đúng theo tài liệu MySQL, dữ liệu cụ thể (tên bảng, id) chỉ mang tính ví dụ.)

SELECT FOR UPDATE — có và không có SKIP LOCKEDKhông có SKIP LOCKEDRow 101 (Buyer A)Row 102 (free)Buyer BWAIT — đứng chờrow 101 mở khóaCó SKIP LOCKEDRow 101 (Buyer A)Row 102 (free)skip quaBuyer Bcommit ngay lập tứctrên row 102Kết quả: cả 2 buyer cùng commit song song, không ai phải xếp hàng chờ ai

7. Vấn đề mới: bùng nổ số dòng → capped pool và cơ chế replenishment

Cách tiếp cận này lại tạo ra vấn đề mới: với hàng triệu merchant, mỗi merchant có hàng nghìn item, mỗi item có hàng trăm đến hàng chục nghìn đơn vị tồn kho, số lượng row bùng nổ. Trước đây một item dù có 10.000 đơn vị chỉ cần một row; giờ thành 10.000 row. Nhân với số lượng merchant và item, con số lên tới hàng triệu row. Tệ hơn, phần lớn các row này gần như không làm gì cả — một merchant có thể có 10.000 hoodie trong kho, nhưng sẽ không bao giờ có 10.000 người checkout cùng lúc. Kết quả là một database khổng lồ chứa hàng triệu row nhàn rỗi, chỉ để phục vụ vài chục reservation cạnh tranh cùng lúc — kéo theo chi phí replicate, backup, bảo trì.

Giải pháp mà Shopify chọn: giới hạn (cap) pool ở tối đa 1.000 row cho mỗi item (chính xác hơn, theo dữ liệu production thật là mỗi cặp item/location), bất kể tồn kho thực tế lớn cỡ nào. Pool này được refill từ tồn kho thật của merchant khi nó cạn dần. Con số 1.000 trong thực tế trở thành mức độ contention tối đa mà hệ thống chấp nhận xử lý cùng lúc — đủ để chịu được một đợt 1.000 người cùng mua cùng lúc. Tồn kho thật vẫn nằm ở bảng inventory như trước; pool chỉ được sizing cho mức concurrency hợp lý thay vì hàng triệu.

Việc refill xảy ra ngay trên đường đi reserve: trong một flash sale đủ lớn, pool có thể bị rút cạn hoàn toàn, nhưng khi một checkout đến lấy row mà không còn gì, chính request đó sẽ tự thực hiện refill trước khi thử lấy lại từ pool. Con số 1.000 nhìn có vẻ hơi tùy tiện, nhưng Shopify chọn nó dựa trên việc quan sát peak reservation rate thực tế: mức cao nhất từng ghi nhận chỉ khoảng vài trăm, nên họ thêm buffer và chốt ở 1.000.

💡 Hình dung như thế này: giống một quầy valet chỉ giữ sẵn 1.000 tấm vé số trong khay, dù bãi xe thực tế có sức chứa hàng chục nghìn xe. Khi khay vé gần hết, nhân viên valet mới lấy thêm một xấp vé mới đánh số tiếp theo từ sổ tổng — không cần in sẵn hàng chục nghìn tấm vé để rồi phần lớn nằm im trong ngăn kéo không dùng tới.

Capped Pool + ReplenishmentInventory Ledgertồn kho thật(vd 10,000 units)replenishReservation Poolcap 1,000 rows1 row = 1 sellable unitreserveBuyers checkoutrút row dầnSKIP LOCKEDkhi pool cạn, chính request reserve đang chờ tự trigger replenish — không cần cron job riêngreservable = tồn kho chưa bán − reservation còn hiệu lực (cap 1,000)reservation hết hạn tự "biến mất" khỏi công thức ở lần replenish kế tiếp —không cần job dọn dẹp riêng

Cơ chế replenishment

Khi replenishment chạy, nó phải trả lời một câu hỏi: hiện tại có bao nhiêu đơn vị nên được đưa vào pool để reserve? Con số đó bằng:

số đơn vị reservable = tồn kho merchant chưa bán - số đơn vị đang bị giữ bởi reservation còn hiệu lực
(và luôn giới hạn ở mức 1.000)

Ví dụ: merchant có 800 hoodie chưa bán, 200 buyer đang ở bước checkout với reservation còn sống. 800 − 200 = 600, dưới mức trần 1.000, nên pool cần có 600 row. Nếu hiện tại pool đã có 400 row, replenishment sẽ chèn thêm 200 row nữa.

Giờ giả sử 50 trong số 200 buyer đó đóng laptop và để reservation hết hạn. Điểm hay của hệ thống này là không cần một job riêng để dọn dẹp — lần replenishment kế tiếp chạy, chỉ còn 150 reservation còn sống, nên con số tính ra là 650, và 50 đơn vị đó tự động quay lại pool. Đây chính là cùng một kiểu "self-healing" như ở kịch bản Redis: người cần tồn kho chính là người kích hoạt việc reclaim, thay vì chờ một cron job chạy theo lịch.

8. Thực tế production: những chi tiết Shopify tiết lộ trong blog gốc

Phần phân tích ở trên bám theo đúng những gì video Hello Interview trình bày. Nhưng blog kỹ thuật gốc của Shopify Engineering còn tiết lộ thêm một số quyết định kỹ thuật quan trọng mà video không đề cập tới — phần này tổng hợp riêng từ blog gốc đó (xem nguồn ở cuối bài), không phải nội dung của video.

Composite primary key. Ban đầu bảng reservation dùng auto-increment primary key, khiến mỗi reservation phải giữ tới 2 row lock. Shopify đổi sang composite primary key (shop_id, inventory_item_id, inventory_group_id, id) — đưa chính các cột nằm trong mệnh đề WHERE vào làm một phần của primary key — nhờ đó mỗi reservation chỉ còn cần 1 row lock.

Đổi transaction isolation level. Isolation level là mức độ một transaction được cách ly khỏi thay đổi của các transaction khác đang chạy song song — càng chặt thì dữ liệu càng nhất quán, nhưng càng dễ gây khóa lẫn nhau. MySQL mặc định chạy ở mức REPEATABLE READ, và ở mức này InnoDB còn đặt thêm gap lock — khóa cả khoảng trống giữa các index record, kể cả khi không có row nào nằm ở đó, nhằm tránh phantom read — ngay cả trên một bảng đang rỗng. Điều này chặn đứng quá trình replenishment khi pool cạn hoàn toàn, vì lệnh insert mới bị vướng gap lock của transaction khác. Shopify đổi sang READ COMMITTED cho các transaction liên quan, vì ở mức này InnoDB không đặt gap lock theo kiểu đó nữa, nên replenishment chạy được bình thường.

Thứ tự lock nhất quán để tránh deadlock. Vì reserve và claim đụng vào hai bảng khác nhau (units và reserved_quantities), nếu không cẩn thận, hai luồng có thể lock hai bảng theo thứ tự ngược nhau và gây deadlock. Giải pháp là cố định thứ tự: reserve luôn DELETE từ bảng units trước rồi mới INSERT vào reserved_quantities, còn claim chỉ đụng vào reserved_quantities.

Batch bằng UNION ALL. Với đơn hàng có nhiều dòng sản phẩm (multi-line item), hệ thống gom tất cả các unit cần reserve vào một round trip duy nhất bằng UNION ALL, thay vì gửi nhiều query riêng lẻ.

Bottleneck thật không nằm ở CPU hay query — mà ở connection. Khi rollout, Shopify quan sát thấy thread bị queue trong MySQL và CPU spike mỗi khi phần việc bị queue đó chạy, nhưng CPU không hề max ra và các query vốn đã được tối ưu — dấu hiệu cho thấy vấn đề không nằm ở đó. Nguyên nhân thật là connection exhaustion ở tầng ProxySQL — một lớp proxy trung gian đứng giữa ứng dụng và MySQL, chịu trách nhiệm quản lý pool connection và định tuyến query. Để tìm ra, họ gắn comment tag vào SQL (dạng /* conn_tag:checkout_completion */) rồi dùng ProxySQL đo thời gian mỗi caller giữ connection. Kết quả: không phải phần reservation chạy chậm, mà các bước khác trong checkout path đang giữ connection lâu hơn mức cần thiết. Sau khi dọn lại checkout path, họ loại bỏ được 50% lượt read và 33% transaction trên primary database, đồng thời tăng InnoDB thread concurrency — CPU của writer giữ dưới 50%, CPU của reader dưới 16%, còn nhiều headroom.

Rollout theo "shadow mode". Trong giai đoạn chuyển đổi, cả Redis và MySQL chạy song song, Redis vẫn là source of truth để so sánh đối chiếu, cho tới khi MySQL được verify đủ tin cậy trên traffic thật thì mới cutover. Việc rollout diễn ra dần dần theo từng pod, đi từ nhóm merchant traffic thấp lên tới nhóm merchant traffic cao nhất.

Kết quả: hệ thống mới đã đứng vững qua Black Friday 2025 — thời điểm Shopify ghi nhận doanh số kỷ lục 5,1 triệu USD mỗi phút ở đỉnh điểm, tăng 11% so với năm trước, trong bối cảnh Shopify chiếm khoảng 14% thương mại điện tử Mỹ.

9. Takeaways

  • Đa số team cho rằng MySQL/Postgres không chịu được scale của họ, nên tách các hot path ra một in-memory store để giảm tải — đó là một lựa chọn hoàn toàn hợp lý. Redis là lựa chọn đúng cho Shopify trong nhiều năm, và blog của họ cũng thừa nhận thẳng rằng nó xử lý throughput rất tốt.
  • Điều thực sự đẩy Shopify phải đổi hướng không phải là vấn đề scale, mà là việc reservation và inventory sống ở hai hệ thống khác nhau, nên mỗi khi thanh toán thành công, họ phải thực hiện hai lần ghi không thể commit cùng nhau. Bài học rút ra: nếu hai thứ bắt buộc phải atomic với nhau, chúng thực sự cần sống trong cùng một data store.
  • MySQL và Postgres có thể xử lý throughput cao hơn nhiều so với những gì người ta thường nghĩ. Phần lớn hệ thống không có volume ngang Shopify, nên nếu Shopify xử lý được mức này, khả năng cao hệ thống của bạn cũng làm được — và đó là một điểm khởi đầu hoàn toàn hợp lý.
  • Việc Shopify từng loại bỏ MySQL ban đầu là quyết định đúng ở thời điểm đó — SKIP LOCKED chưa tồn tại. Cái thay đổi là bản thân database, không phải cách suy luận của họ. Vì vậy, nếu một team từng đưa ra quyết định kỹ thuật từ lâu, việc quay lại xem xét công nghệ hiện tại đã tiến hoá đến đâu là điều đáng làm — biết đâu một phần độ phức tạp từng thêm vào hệ thống giờ có thể được đơn giản hoá lại.

Không có công nghệ nào là "đúng mãi mãi" hay "sai mãi mãi" — chỉ có công nghệ đúng với ràng buộc của thời điểm nó được chọn. Redis đúng khi Shopify cần throughput mà MySQL năm đó chưa đáp ứng nổi. MySQL đúng trở lại khi SKIP LOCKED xóa bỏ chính ràng buộc đó. Bài học lớn nhất không nằm ở việc chọn Redis hay MySQL, mà ở việc biết đúng lúc nào cần quay lại hỏi: giả định cũ liệu còn đúng không?


Nguồn tham khảo

Nguồn gốc: Why Shopify Moved Inventory Reservations from Redis to MySQL — kênh Hello Interview

Ghi chú kỹ thuật (fact-check bổ sung) — mục 8 và các đính chính trong mục 6, 7: