Cả Apple và Google đều có thể gửi cùng một khoản hoàn tiền đến máy chủ của bạn nhiều hơn một lần, và thông báo hoàn tiền trùng lặp sẽ khiến bạn tốn kém nếu bạn xử lý từng thông báo
Apple thử lại thông báo hoàn tiền tối đa năm lần và Google Play chạy trên Pub/Sub với đảm bảo gửi ít nhất một lần, nên cùng một khoản hoàn tiền có thể đến máy chủ của bạn nhiều hơn một lần. Đây là cách xử lý thông báo hoàn tiền trùng lặp mà không trừ số dư hay đốt hạn ngạch API hai lần.

Điểm chính
- Apple thử lại một App Store Server Notification V2 năm lần, vào 1, 12, 24, 48 và 72 giờ sau lần thử cuối, mỗi khi máy chủ của bạn không trả lời bằng trạng thái HTTP trong khoảng 200 đến 206. Tính cả lần thử đầu tiên, một khoản hoàn tiền có thể đến tối đa sáu lần.
- Mỗi lần thử lại thật sự của Apple đều mang cùng một notificationUUID, nên chính trường đó, chứ không phải id giao dịch, là khóa khử trùng lặp của bạn.
- Real-time Developer Notifications của Google Play chạy trên Cloud Pub/Sub, vốn đảm bảo gửi ít nhất một lần và không đảm bảo thứ tự, nên cùng một thông điệp có thể đến hai lần hoặc sai thứ tự. Google bảo bạn kiểm tra tính duy nhất của messageId trước khi xử lý bất cứ điều gì.
- Các thông báo CONSUMPTION_REQUEST định kỳ của Apple không phải là lần thử lại. Apple tiếp tục gửi các thông báo mới trong suốt cửa sổ hoàn tiền đang mở, mỗi thông báo có một notificationUUID khác nhau, nên khử trùng lặp theo notificationUUID sẽ giữ lại đúng từng thông báo một.
- Cùng một transactionId của Apple có thể mang nhiều hơn một quyết định, ví dụ một REFUND_DECLINED rồi sau đó là một REFUND, nên khử trùng lặp chỉ theo id giao dịch sẽ vứt bỏ một sự kiện riêng biệt mà bạn cần.
- Từ chối một bản trùng lặp bằng cách trả về 4xx hoặc 5xx chỉ khiến cửa hàng thử lại nó. Hãy khử trùng lặp bên trong cơ sở dữ liệu của chính bạn và luôn trả về một trạng thái thành công.
- Một trình xử lý hoàn tiền không idempotent sẽ hành động hai lần ở lần gửi thứ hai. Nó trừ số dư hai lần, đảo ngược một khoản chi trả hai lần, hoặc đốt hạn ngạch tính phí của Play Developer và App Store Server API để kiểm tra lại một khoản hoàn tiền mà nó đã đóng.
Máy chủ của bạn sẽ nhận cùng một sự kiện hoàn tiền nhiều hơn một lần, và cả hai cửa hàng đều thiết kế như vậy một cách có chủ đích. Apple thử lại một App Store Server Notification tối đa năm lần khi điểm cuối của bạn không trả lời một cách sạch sẽ. Google Play gửi Real-time Developer Notifications của mình qua Cloud Pub/Sub, vốn hứa hẹn gửi ít nhất một lần và không hứa hẹn gì về thứ tự. Vậy nên câu hỏi không bao giờ là liệu một bản trùng lặp có đến hay không. Đó là điều mã của bạn làm vào lần thứ hai nó thấy cùng một khoản hoàn tiền. Làm sai chỗ đó, và bạn trừ số dư hai lần, đảo ngược một khoản chi trả hai lần, hoặc đốt hạn ngạch API tính phí để kiểm tra lại một khoản hoàn tiền mà bạn đã đóng. Đây là cách các thông báo hoàn tiền trùng lặp thực sự đến với bạn, những lần lặp lại nào là trùng lặp thật và những lần nào chỉ trông giống vậy, và cách xử lý chúng sao cho lần gửi thứ hai là miễn phí.
Một thông báo hoàn tiền được gửi ít nhất một lần, điều này không giống với đúng một lần
Cả hai cửa hàng đều coi một thông báo đã gửi là một lời hứa mà họ tiếp tục cố hoàn thành, chứ không phải một phát bắn duy nhất mà họ bắn rồi quên. Điều đó tốt cho độ tin cậy, vì một thông báo bạn bỏ lỡ trong lúc triển khai vẫn đến được với bạn sau đó. Đó là một cái bẫy cho tính đúng đắn, vì cơ chế đảm bảo cuối cùng bạn nhận được sự kiện cũng đảm bảo đôi khi bạn nhận nó hai lần. Trình xử lý của bạn phải idempotent, nghĩa là lần gửi thứ hai và thứ ba của một khoản hoàn tiền không thay đổi gì mà lần đầu tiên chưa thay đổi.
Apple thử lại năm lần trong ba ngày
Khi Apple gửi một App Store Server Notification V2, nó mong đợi máy chủ của bạn trả lời bằng một trạng thái HTTP trong khoảng 200 đến 206. Bất cứ thứ gì khác, một 4xx hoặc một 5xx, báo cho Apple rằng việc gửi thất bại, và Apple thử lại. Lịch trình là cố định: năm lần thử lại, vào 1, 12, 24, 48 và 72 giờ sau lần thử trước. Tính cả lần thử đầu tiên, một sự kiện hoàn tiền có thể đến tối đa sáu lần, trải ra trong khoảng một tuần. Mỗi lần thử lại đó đều mang cùng một notificationUUID. Trường đó là khóa khử trùng lặp của bạn. Nếu bạn đã ghi lại một notificationUUID, lần gửi bạn đang cầm là một bản lặp lại, và phản hồi đúng là không lưu gì mới và vẫn trả về 200.
Google Play chạy trên Pub/Sub, vốn hứa hẹn ít nhất một lần và không nói gì về thứ tự
Real-time Developer Notifications của Google Play được xuất bản lên một chủ đề Cloud Pub/Sub. Đảm bảo gửi của Pub/Sub là ít nhất một lần, và nó hoàn toàn không đưa ra đảm bảo nào về thứ tự. Điều đó có nghĩa là cùng một thông điệp có thể được gửi đến điểm cuối của bạn nhiều hơn một lần, và hai thông điệp cho cùng một giao dịch mua có thể đến sai thứ tự. Hướng dẫn của chính Google nói rõ: giải nén trường data base64, đọc messageId, và kiểm tra rằng bạn chưa từng thấy nó trước khi xử lý bất cứ điều gì. Một messageId trùng lặp là một bản lặp lại bạn bỏ qua. Hai thông báo khác nhau về một giao dịch mua vẫn phải rơi vào cùng một bản ghi, nên hãy khóa trạng thái đã lưu của bạn theo cả purchaseToken, và để một sự kiện đến sau cập nhật hàng mà một sự kiện đến trước đã tạo.
| Nền tảng | Mô hình gửi | Khử trùng lặp theo | Tín hiệu thành công | Nếu bạn không xác nhận |
|---|---|---|---|---|
| App Store Server Notifications V2 | Tối đa 6 lần thử: lần đầu, cộng 5 lần thử lại vào 1, 12, 24, 48, 72 giờ | notificationUUID | HTTP 200 đến 206 | Apple thử lại theo lịch cố định, rồi dừng |
| Google Play RTDN qua Pub/Sub | Ít nhất một lần, không đảm bảo thứ tự | Pub/Sub messageId, khóa thực thể theo purchaseToken | HTTP 200 cho lần push, hoặc một ack rõ ràng | Pub/Sub gửi lại sau khi hết hạn chót ack |
Những lần lặp lại không phải là trùng lặp
Không phải mọi thông báo trông giống một thông báo bạn đã thấy đều là một lần thử lại. Hai hành vi của Apple gửi những sự kiện thực sự mới cùng chia sẻ một giao dịch mua nhưng mỗi cái phải được xử lý, và gộp chúng lại bằng một cách khử trùng lặp ngây thơ sẽ vứt bỏ thông tin bạn cần.
Apple gửi các CONSUMPTION_REQUEST mới, không phải lần thử lại
Trong một yêu cầu hoàn tiền đang mở trên một mặt hàng tiêu dùng, Apple không gửi một CONSUMPTION_REQUEST rồi chờ. Nó gửi những cái mới định kỳ trong suốt toàn bộ cửa sổ hoàn tiền đang mở cho đến khi khoản hoàn tiền được đóng. Nhân viên Apple đã xác nhận rằng đây không phải là lần thử lại, và dấu hiệu nằm ở trường bạn dùng để khử trùng lặp: mỗi CONSUMPTION_REQUEST mới mang một notificationUUID khác. Vậy nên một cách khử trùng lặp khóa theo notificationUUID tự động làm điều đúng. Nó gộp các lần thử lại thật và giữ lại từng lời nhắc riêng biệt. Điều bạn không được làm là khử trùng lặp theo id giao dịch và loại thông báo, vì điều đó sẽ làm im lặng mọi CONSUMPTION_REQUEST sau cái đầu tiên và khiến bạn tốn cửa sổ bằng chứng 12 giờ trên những cái bạn đã bỏ.
Một giao dịch có thể mang nhiều hơn một quyết định
Một transactionId duy nhất có thể tạo ra nhiều hơn một kết quả hoàn tiền trong vòng đời của nó. Apple có thể gửi một REFUND_DECLINED rồi, sau đó, một REFUND cho cùng một giao dịch, và các nhà phát triển báo cáo nhận được ba hoặc nhiều hơn các thông báo liên quan đến hoàn tiền cho một id giao dịch. Mỗi cái là một sự kiện riêng biệt với notificationUUID của riêng nó. Nếu khóa khử trùng lặp của bạn là id giao dịch, quyết định thứ hai trông giống một bản trùng lặp của quyết định thứ nhất và bạn không bao giờ biết được rằng khoản hoàn tiền cuối cùng đã được chấp thuận. Id giao dịch nhóm các sự kiện lại. Nó không định danh chúng.

Một bản trùng lặp thực sự khiến bạn tốn gì
Một thông báo hoàn tiền không phải là một đèn báo trạng thái. Nó kích hoạt những hành động thực sự: bạn thu hồi quyền truy cập, bạn trừ một số dư mặt hàng tiêu dùng, bạn đảo ngược một khoản chi trả cho nhà sáng tạo, bạn gọi App Store Server API hoặc Play Developer API để xác nhận trạng thái. Chạy bất cứ hành động nào trong số đó lần thứ hai trên một bản trùng lặp và cái giá là có thật.
Hãy lần theo dòng tiền. Thu hồi quyền truy cập hai lần là vô hại, vì quyền truy cập đã mất rồi. Trừ một số dư hai lần thì không: một người dùng đã mua một gói xu rồi hoàn tiền nó có thể bị đẩy xuống số dư âm mà đội hỗ trợ của bạn sau đó phải gỡ rối bằng tay. Đảo ngược một khoản chi trả hai lần sẽ đòi lại số tiền bạn đã trả lại một lần rồi, và giờ bạn nợ một nhà sáng tạo một lời xin lỗi và một sự chỉnh sửa. Và mỗi bản trùng lặp bạn xử lý lại với một API cửa hàng đều tiêu tốn hạn ngạch mà Google cảnh báo rõ ràng để bạn bảo vệ, nên một đợt bùng nổ gửi lại của Pub/Sub trong một sự cố có thể đẩy bạn vào giới hạn tốc độ đúng vào ngày bạn ít có thể chịu đựng nó nhất.
Các cửa sổ bằng chứng hoàn tiền nâng cao mức đặt cược ở phía Apple. Nếu một cách khử trùng lặp ngây thơ làm im lặng các CONSUMPTION_REQUEST lặp lại mà Apple gửi trong suốt cửa sổ hoàn tiền đang mở, bạn có thể bỏ lỡ cái mà bạn cần trả lời, và một CONSUMPTION_REQUEST mà bạn không trả lời trong vòng 12 giờ là một khoản hoàn tiền mà Apple thường chấp thuận theo mặc định. Đó không phải là một lần tính phí kép. Đó là một khoản bán hàng bị mất cộng với công suất tính toán, các lệnh gọi API, lưu trữ, và các khoản chi trả mà bạn đã bỏ ra để giao giao dịch mua, không thứ nào trong số đó được khoản hoàn tiền trả lại.
| Chế độ lỗi | Điều gì sai | Nó khiến bạn tốn gì |
|---|---|---|
| Trừ lại số dư trên một REFUND trùng lặp | Số dư mặt hàng tiêu dùng của người dùng bị âm | Thời gian hỗ trợ thủ công để đối soát, và một trải nghiệm khách hàng tệ |
| Đảo ngược một khoản chi trả hai lần | Bạn đòi lại số tiền đã trả lại một lần rồi | Một sự chỉnh sửa cho nhà sáng tạo và một đợt dọn dẹp kế toán |
| Xử lý lại với một API cửa hàng | Các lệnh gọi trùng lặp đốt hạn ngạch Play Developer hoặc App Store Server API | Giới hạn tốc độ trong sự cố đã gây ra việc gửi lại |
| Khử trùng lặp CONSUMPTION_REQUEST quá mức | Bạn bỏ một lời nhắc hoàn tiền riêng biệt như một bản trùng lặp giả | Một cửa sổ 12 giờ bị bỏ lỡ, nên Apple chấp thuận khoản hoàn tiền theo mặc định |
Cách xử lý thông báo hoàn tiền trùng lặp mà không hành động hai lần
Khuôn mẫu là như nhau trên cả hai cửa hàng, chỉ với một khóa khác. Ghi lại lần gửi, kiểm tra khóa trước khi bạn hành động, hành động một lần, và luôn báo cho cửa hàng rằng bạn đã nhận được nó.
- Khử trùng lặp theo id gửi của cửa hàng, không phải theo giao dịch. Dùng
notificationUUIDcho Apple và Pub/SubmessageIdcho Google Play. Lưu nó với một ràng buộc duy nhất để một bản trùng lặp đồng thời thua cuộc đua thay vì hành động hai lần. - Làm cho hành động phía sau idempotent theo cách riêng của nó. Khóa theo id gửi ngăn việc xử lý lại, nhưng cũng hãy viết tác động sao cho việc thu hồi, trừ, hoặc đảo ngược kiểm tra trạng thái hiện tại trước và an toàn khi chạy hai lần.
- Lưu trước, rồi mới xác nhận. Ghi sự kiện vào cơ sở dữ liệu của bạn trước khi bạn trả về 200 hoặc ack thông điệp Pub/Sub. Nếu bạn ack trước và việc ghi thất bại, cửa hàng coi thông điệp đã được gửi và không bao giờ gửi lại, và giờ bạn đã mất nó vĩnh viễn.
- Luôn trả về một trạng thái thành công, ngay cả cho một bản trùng lặp. 200 đến 206 cho Apple, một 200 cho lần push của Pub/Sub cho Google. Từ chối một bản lặp lại bằng một lỗi chỉ khiến cửa hàng thử lại nó.
- Nhóm theo thực thể, định danh theo sự kiện. Khóa trạng thái giao dịch mua đã lưu của bạn theo
purchaseTokenhoặcoriginalTransactionIdđể các lần gửi sai thứ tự cập nhật một hàng, nhưng hãy coi mỗinotificationUUIDhoặcmessageIdlà sự kiện của riêng nó, vì một giao dịch mua một cách chính đáng tạo ra vài sự kiện.
Một danh sách kiểm tra ngắn trước khi bạn tin tưởng webhook hoàn tiền của mình
- Các lần gửi của Apple được khử trùng lặp theo
notificationUUID, và một lần lặp lại không ghi gì mới nhưng vẫn trả về 200. - Các lần gửi của Google Play được khử trùng lặp theo Pub/Sub
messageId, được kiểm tra trước bất kỳ việc xử lý nào. - Trạng thái giao dịch mua được khóa theo
purchaseTokenhoặcoriginalTransactionId, để các sự kiện sai thứ tự rơi vào một bản ghi. - Mọi tác dụng phụ hoàn tiền, thu hồi, trừ, hoặc đảo ngược, đều an toàn để chạy nhiều hơn một lần.
- Trình xử lý của bạn ghi sự kiện trước khi nó xác nhận, không bao giờ sau đó.
- Các CONSUMPTION_REQUEST lặp lại được xử lý như những lời nhắc riêng biệt, không phải trùng lặp, nên không có cửa sổ hoàn tiền đang mở nào bị bỏ.
Cố tình cho một bản trùng lặp chạy qua webhook của chính bạn và xem nó không thay đổi gì vào lần thứ hai. Đó là toàn bộ bài kiểm tra. Một trình xử lý hoàn tiền an toàn khi bị gọi hai lần là một trình xử lý mà bạn có thể ngừng lo lắng ngay khoảnh khắc một cửa hàng quyết định gọi nó sáu lần.
Câu hỏi thường gặp
- Tại sao máy chủ của tôi nhận cùng một thông báo hoàn tiền của App Store nhiều hơn một lần?
- Vì Apple thử lại một App Store Server Notification V2 tối đa năm lần, vào 1, 12, 24, 48 và 72 giờ sau lần thử cuối, mỗi khi máy chủ của bạn không phản hồi bằng một trạng thái HTTP trong khoảng 200 đến 206. Mỗi lần thử lại đều mang cùng một notificationUUID, nên bạn có thể nhận ra và bỏ qua nó.
- Tôi nên dùng trường nào để khử trùng lặp App Store Server Notifications?
- Hãy dùng notificationUUID. Một lần thử lại thật luôn lặp lại cùng một notificationUUID, trong khi mỗi sự kiện thực sự mới, bao gồm cả mỗi CONSUMPTION_REQUEST mới, nhận một cái khác, nên khử trùng lặp theo notificationUUID bỏ qua các bản lặp lại mà không vứt bỏ các sự kiện riêng biệt.
- Các thông báo CONSUMPTION_REQUEST lặp lại có phải là bản trùng lặp mà tôi nên bỏ qua không?
- Không. Apple gửi các thông báo CONSUMPTION_REQUEST mới định kỳ trong suốt cửa sổ hoàn tiền đang mở, và nhân viên Apple xác nhận đây không phải là lần thử lại. Mỗi cái có notificationUUID của riêng nó, nên hãy xử lý từng cái một. Bỏ chúng có nguy cơ bỏ lỡ cửa sổ 12 giờ mà Apple cho bạn để phản hồi.
- Làm thế nào để tôi khử trùng lặp Google Play Real-time Developer Notifications?
- Đọc Pub/Sub messageId từ mỗi thông báo và đối chiếu nó với những cái bạn đã xử lý trước khi hành động, vì Pub/Sub gửi ít nhất một lần và có thể gửi cùng một thông điệp nhiều hơn một lần. Google khuyến nghị điều này một cách rõ ràng để tránh xử lý trùng lặp và lãng phí hạn ngạch API.
- Tôi có nên trả về một lỗi để từ chối một thông báo hoàn tiền trùng lặp không?
- Không. Trả về một 4xx hoặc 5xx báo cho cửa hàng rằng việc gửi thất bại, nên nó vẫn thử lại. Hãy khử trùng lặp bên trong cơ sở dữ liệu của chính bạn và luôn trả về một trạng thái thành công, HTTP 200 đến 206 cho Apple hoặc một xác nhận 200 cho lần push của Google Play.
Nguồn và tài liệu đọc thêm
- Apple Developer: App Store Server Notifications V2
- Apple Developer: Responding to App Store Server Notifications
- Apple Developer: notificationUUID
- Apple Developer Forums: App Store Server Notifications V2 retry schedule and CONSUMPTION_REQUEST clarification
- Android Developers: Real-time developer notifications reference
- Android Developers: Purchase lifecycle and RTDNs
- Google Cloud: Pub/Sub subscriber and at-least-once delivery
RefundHalt
Chế độ tự động xử lý hoàn tiền cho App Store và Google Play
Đọc tiếp
Một khoản hoàn tiền Family Sharing đảo ngược một lần thanh toán nhưng có thể để lại năm người khác vẫn đang dùng ứng dụng của bạn, và chỉ máy chủ của bạn mới cắt được quyền của họ
Một khoản hoàn tiền Family Sharing đảo ngược một lần thanh toán nhưng có thể để lại tới năm thành viên gia đình vẫn dùng các tính năng trả phí của bạn. Apple gửi một REVOKE và mong máy chủ của bạn chấm dứt quyền truy cập đó. Đây là cách hoàn tiền chia sẻ gia đình hoạt động và một lần như vậy tốn kém ra sao.
Xử lý hoàn tiền hỏng theo cách âm thầm, nên hãy test hoàn tiền mua hàng trong ứng dụng ở sandbox trước khi một khách hàng thật gặp phải
Việc xử lý hoàn tiền chỉ chạy sau khi khách hàng đã rời đi, nên một lỗi trong đó vẫn vô hình cho đến khi nó gây thiệt hại tiền thật. Cả hai cửa hàng đều cho phép bạn kích hoạt một khoản hoàn tiền trong môi trường test trước. Dưới đây là cách test hoàn tiền mua hàng trong ứng dụng trên App Store và Google Play trước khi một khoản hoàn tiền trở thành thật.