Chống Bán Vượt Tồn Kho Trên Nhiều Store Shopify
Tôi đã chặn oversell cùng một SKU trên nhiều store Shopify thế nào — lỗ hổng webhook đứng sau nó, và cách sync idempotent + reconciliation đã cứu.
Điểm chính — AI tóm tắt
- Oversell giữa nhiều store hiếm khi là lỗi "con số sai" — nó là một pipeline sync âm thầm lệch dần vì đếm trùng, bỏ sót, hoặc đảo thứ tự các event tồn kho
- Shopify nói thẳng rằng webhook không đảm bảo giao đủ, và khuyến nghị dedupe theo X-Shopify-Webhook-Id kèm job reconciliation định kỳ — hãy build cho thực tế đó, đừng build cho một luồng hoàn hảo
- Làm handler idempotent trước tiên: lưu ID các webhook đã xử lý và bỏ qua bản lặp, để cùng một event chạy hai lần không trừ kho hai lần
- Một job reconciliation chạy hàng đêm đọc lại số liệu từ Admin API và sửa chỗ lệch chính là lưới an toàn mà webhook không thể là — coi webhook là luồng nhanh, còn API mới là nguồn sự thật
- Cảnh báo khi hai store lệch nhau trước khi nó thành một đơn oversold — bắt lệch sớm rẻ hơn nhiều so với hủy đơn của khách sau khi sự đã rồi
AI tổng hợp từ chính nội dung bài viết; tác giả đã soát lại.
Trong bài này
Tháng 4/2026, một trong những store chúng tôi vận hành bán vượt tồn một sản phẩm chỉ tồn tại đúng một cái ngoài đời thật. Cùng một SKU đang live trên ba storefront dùng chung một kho, một khách mua nốt cái cuối cùng ở Store A, và mười một phút sau một khách thứ hai lại mua "cái cuối cùng" một lần nữa ở Store C. Hai đơn, một món hàng, một lần refund, và một email mở đầu bằng câu "vậy rốt cuộc hàng của tôi đang ở đâu". Lần refund đó tốn của chúng tôi cỡ mười lăm đô. Cái mất niềm tin thì tốn hơn, mà tôi không nói được con số bao nhiêu — chính vì thế nó mới làm tôi bứt rứt. Một lần refund thì chỉ là sai số làm tròn; nhưng cái pattern thì không — IHL Group ước tính bán lẻ toàn cầu mất hơn 1,7 nghìn tỷ USD mỗi năm vì hết hàng và tồn dư, và một cái kho dùng chung âm thầm lệch dần chính là cách bạn góp mặt vào con số đó, mỗi lần một đơn oversold.
Lớp sync tồn kho là do chính tôi build, nên tôi chẳng đổ lỗi được cho app nào. Bài này là những gì tôi thấy khi mở nó ra: vì sao oversell giữa nhiều store Shopify gần như không bao giờ là lỗi "con số bị sai", mà gần như luôn là lỗi "cái pipeline duy trì con số đó đã lệch". Nếu bạn muốn góc nhìn chiến lược về chuyện giữ tồn kho khớp nhau giữa các storefront, tôi viết riêng ở đồng bộ tồn kho giữa nhiều store Shopify. Bài này là bản post-mortem thuần kỹ thuật.
Con số đúng, cho tới lúc nó không đúng nữa
Giả định đầu tiên của tôi là có store nào đó ôm một con số cũ từ đầu. Không phải. Sáng hôm xảy ra sự cố tôi export tồn kho cả ba store, mọi con số đều khớp nhau. Chỗ lệch phát sinh trong mười một phút đó, dưới tải cao — đúng cái thời điểm tệ nhất để đi debug bất cứ thứ gì.
Đây là hình dạng thứ tôi đã build. Mỗi store bắn webhook orders/create và inventory_levels/update về endpoint Node.js của tôi. Handler đọc event, tính ra số lượng kho mới của cả pool, rồi đẩy con số đã sửa ngược lại hai store còn lại qua Admin API. Nó chạy hoàn hảo lúc test. Nó chạy hoàn hảo suốt mấy tháng. Nó chỉ hỏng đúng lúc hai đơn đáp xuống sát nhau, vì handler của tôi coi mọi webhook như một sự thật tươi mới, duy nhất, chạy đúng một lần.
Webhook chẳng phải thứ nào trong ba thứ đó cả. Đó là toàn bộ con bug — và chính tôi đã thiết kế nó vào.
Shopify thực sự cam kết gì về việc giao webhook
Tôi quay lại nguồn gốc thay vì tin vào phỏng đoán của mình, và tôi khuyên ai build cái này cũng nên làm vậy. Tài liệu best-practices về webhook của chính Shopify nói thẳng: "Webhook delivery isn't always guaranteed, and your app can miss or mishandle events for other reasons, such as handler failures or downtime." (Việc giao webhook không phải lúc nào cũng được đảm bảo, và app của bạn có thể bỏ lỡ hoặc xử lý sai event vì các lý do khác, chẳng hạn handler lỗi hoặc downtime.)
Hãy đọc câu đó theo cách một người vận hành phải đọc, không phải cách một bản demo đọc. Nó nghĩa là có hai chuyện có thể xảy ra với pipeline tồn kho của bạn, và cả hai đều đã xảy ra với tôi:
- Bạn có thể nhận cùng một event nhiều hơn một lần. Khuyến nghị của Shopify là "ignore duplicate deliveries using
X-Shopify-Webhook-Id" — một header để dedupe tồn tại chính vì bản lặp là điều được dự tính trước, không phải ngoại lệ. - *Bạn có thể âm thầm không nhận được event nào cả.* Việc giao không được đảm bảo. Một handler timeout, một lần deploy sai đúng cái giây đó, một cú nấc ở tầng dưới — event mất, và Shopify sẽ không xin lỗi.
Handler của tôi không có phòng thủ nào cho cả hai. Khi hai event orders/create cho cái pool dùng chung đáp xuống gần như đồng thời, cả hai cùng đọc thấy "còn 1 trong kho", cả hai cùng quyết định giá trị pool mới, và cả hai cùng ghi. Không cái nào thấy cái kia. Một cuộc đua read-modify-write kinh điển, cải trang thành bài toán tồn kho. Nếu bạn còn mơ hồ về việc mấy event này được giao và retry ra sao ngay từ đầu, thì cơ chế đó đáng để hiểu cho chắc — tôi trình bày hết trong webhook Shopify cơ bản, vì bạn không thể build sync đáng tin trên một mô hình giao mà mình chỉ đang đoán.
Bản vá không ăn thua: cứ thêm cái lock
Bản vá đầu tiên của tôi là cái hiển nhiên nhất — bọc luồng read-modify-write trong một lock để mỗi lần chỉ một webhook được sửa pool. Nó dập tắt cuộc đua. Nhưng nó không làm được gì cho kiểu hỏng thứ hai, và tôi phát hiện điều đó theo cách đau đớn khoảng một tuần sau, khi một lần deploy nuốt trọn một webhook orders/create. Lần này không có bản lặp. Một event bị mất. Tồn kho ở hai store dôi ra đúng một đơn vị suốt gần cả ngày, cho tới khi lại một khách hàng tìm ra cái lỗ đó giùm tôi.
Lock làm cho việc xử lý đồng thời trở nên an toàn. Nó không làm cho việc xử lý đúng khi bản thân luồng đầu vào bị mất mát và thi thoảng lặp lại. Tôi mới giải xong nửa nhỏ của vấn đề rồi tuyên bố thắng lợi — một sai lầm mà tôi cứ phải học lại hoài.
Thứ thực sự cứu được: idempotency trước, reconciliation sau
Bản fix thật sự có hai lớp, và thứ tự rất quan trọng.
Lớp một — làm mọi handler idempotent. Trước khi làm bất cứ gì với một webhook, giờ tôi ghi X-Shopify-Webhook-Id của nó vào một bảng processed-events và kiểm tra bảng đó ngay khi event vào. Đã thấy ID này rồi? Trả 200 và không làm gì thêm. Đây là thay đổi có đòn bẩy cao nhất tôi từng làm. Idempotent nghĩa là "xử lý cùng một event hai lần cho kết quả y hệt xử lý một lần", và một khi có nó, một bản lặp trở thành chuyện không-xảy-ra thay vì một cú trừ kho gấp đôi. Nó còn cho phép bạn retry mạnh tay mà không sợ, vì replay là miễn phí. Shopify trao sẵn cho bạn cái key để dedupe; sai lầm là không dùng nó.
Lớp hai — reconcile theo lịch, vì webhook vẫn sẽ bỏ sót. Idempotency sửa được bản lặp. Nó không làm gì được cho những event chẳng bao giờ tới. Với những cái đó, khuyến nghị của chính Shopify là câu trả lời: "use reconciliation jobs to periodically fetch data from Shopify so that your app stays consistent with Shopify's data." (Dùng job reconciliation để định kỳ kéo dữ liệu từ Shopify về, giữ cho app luôn nhất quán với dữ liệu của Shopify.) Nên giờ một job chạy hàng đêm đọc lại số tồn kho thật từ Admin API của từng store, so với thứ mà database nguồn-sự-thật của tôi đang tin, và sửa mọi chỗ lệch. Webhook là luồng nhanh — nó giữ pool "gần như live" trong vòng vài giây. Job reconciliation mới là sự thật — nó bảo đảm rằng đến sáng hôm sau, dù luồng có làm rơi mất cái gì đi nữa, các con số vẫn hội tụ.
Tôi nói thẳng cái quan điểm này, vì nó là thứ tôi muốn ghim lên bảng: hãy coi webhook như một cách hâm nóng cache, đừng bao giờ coi nó là sổ cái của bạn. Bất kỳ hệ thống tồn kho nào tin luồng webhook là nguồn sự thật đều chỉ cách một event bị rơi là thành một đơn oversold. Hệ của tôi từng như vậy, suốt mấy tháng, và nó trông vẫn ổn suốt thời gian đó.
Bắt chỗ lệch trước khi khách hàng bắt
Mảnh cuối cùng là từ chối chuyện biết mình lệch qua một email nổi giận. Job reconciliation vốn đã tính ra chỗ lệch giữa con số mỗi store báo và con số pool đang tin — nên tôi cho nó cảnh báo về chỗ lệch đó thay vì âm thầm sửa. Nếu Store A nói 4 mà pool nói 6, thì đâu đó ở thượng nguồn đang làm rơi hoặc bắn trùng event, và tôi muốn biết lúc nó còn là khe hở hai đơn vị, chứ không phải sau khi nó thành một đơn bị hủy.
Cái đó cắm thẳng vào đúng lớp alerting tôi dùng cho đơn kẹt vận chuyển và các cú tăng vọt chargeback; tôi đã viết về cách dựng nó trong giám sát sức khỏe store và cảnh báo. Nguyên tắc thì y hệt: cảnh báo theo một ngưỡng lệch, đừng cảnh báo mọi event một, không thì bạn tự luyện cho mình thói quen phớt lờ cái kênh đó. Một cú lệch tròn số một đơn vị trong giờ cao điểm chẳng đáng để ping. Một store đã lệch năm đơn vị và đang tăng thì đáng.
Chỗ này nằm ở đâu trong StoreFleet
Tất cả những gì trên đây là thứ lớp sync tồn kho trong StoreFleet làm cho các store chúng tôi vận hành: nó dedupe theo webhook ID để một event bị replay không thể trừ kho hai lần, nó reconcile với Admin API theo lịch để một event bị rơi không thể để tồn kho dôi lên, và nó phát cảnh báo khi hai store lệch nhau quá một biên đã đặt. Nó chạy trên cùng lớp dữ liệu hợp nhất vốn nuôi dashboard đa store của chúng tôi — một nguồn sự thật duy nhất cho order, vận chuyển và tồn kho trên mọi store, trên hạ tầng bạn sở hữu, không phí tính theo từng store.
Tôi không nghĩ oversell là bài toán giải một lần là xong. Nó là một kỷ luật: mặc định luồng nói dối, làm handler idempotent, reconcile theo đồng hồ, và cảnh báo khi lệch. Làm đủ bốn việc đó thì cùng một SKU trên mười storefront thôi là mối rủi ro. Bỏ qua một trong bốn, thì chỉ còn là chuyện lưu lượng đủ lớn trước khi một khách hàng tìm ra cái lỗ giùm bạn — đúng cách của tôi cứ lặp đi lặp lại, cho tới khi tôi thôi đoán về những gì Shopify thật sự đảm bảo và đọc docs.