Stripe Webhook短时间重复发送同一事件的原因与解决方案咨询
我正在用Stripe搭建支付系统,支付完成后通过invoice.paid事件完成用户授权。测试时发现Webhook会在短时间内重复发送同一事件:不管是网页测试模式支付,还是执行终端命令stripe events resend evt_xxxxxxxxxxxxxxx都会复现,但只在开发环境(用stripe listen --forward-to http://0.0.0.0:8080/webhook/启动)出现,生产环境还没测。
我试过用保存事件ID的方式去重,收到重复事件就返回200,代码如下:
def webhook_view(request): payload = json.loads(request.body) event_id = payload.get('id') event_type = payload.get('type', '') logger.info(f'type: {event_type} event_id: {event_id}') if Event.objects.filter(id=event_id).exists(): logger.info(f'{event_id} already exists') return HttpResponse(status=200) Event(id=event_id, created_at=timezone.now(), data=json.dumps(payload)).save()
但两次请求间隔太短(日志显示仅5毫秒),第二个事件还是能通过重复校验:
[2022-07-24 22:28:53,677] [webhook_view: 60] type: invoice.paid event_id: evt_1LP4n0BB5Q5Dr2x7QDEfWFak [2022-07-24 22:28:53,682] [webhook_view: 60] type: invoice.paid event_id: evt_1LP4n0BB5Q5Dr2x7QDEfWFak
想咨询三个问题:
- 为何会在短时间内发送两次相同事件?
- 是否可以仅发送一次事件,或延迟第二次事件的发送?
- 如何解决该重复事件问题?
1. 短时间重复发送事件的原因
这是Stripe CLI在开发环境的特性:用stripe listen转发事件时,CLI会模拟生产环境的重试机制,但开发场景下可能因本地服务响应极快、网络转发的微小延迟,触发短时间内的重复推送。另外执行stripe events resend时,CLI的内部逻辑也可能导致重复推送。生产环境中Stripe的事件重试间隔是逐步拉长的(几秒、几分钟后重试),不会出现这种毫秒级重复。
2. 能否控制只发送一次或延迟第二次?
对于stripe listen的开发环境,没有直接关闭短时间重复推送的配置,因为它是为了模拟生产环境可能出现的重试场景(比如服务临时宕机、超时)。不过可以尝试:
- 确保本地Webhook服务响应足够快,CLI收到200响应后会停止重试;
- 执行
stripe events resend时添加--no-retry参数(可查看Stripe CLI文档确认支持情况),强制只发送一次事件。
3. 解决重复事件的方案
你的去重逻辑因并发竞态条件失效,需要改成原子操作来避免:
方案一:数据库唯一约束+原子创建
给Event模型的id字段添加唯一约束,用get_or_create原子操作处理事件创建,捕获唯一键冲突异常:
from django.db import IntegrityError, transaction def webhook_view(request): payload = json.loads(request.body) event_id = payload.get('id') event_type = payload.get('type', '') logger.info(f'type: {event_type} event_id: {event_id}') try: with transaction.atomic(): event, created = Event.objects.get_or_create( id=event_id, defaults={'created_at': timezone.now(), 'data': json.dumps(payload)} ) if not created: logger.info(f'{event_id} already exists') return HttpResponse(status=200) except IntegrityError: # 并发场景下唯一键冲突,说明事件已存在 logger.info(f'{event_id} already exists (integrity conflict)') return HttpResponse(status=200) # 此处处理用户授权等业务逻辑 return HttpResponse(status=200)
get_or_create是数据库层面的原子操作,结合唯一约束,能确保同一事件ID只会被创建一次,并发请求无法绕过校验。
方案二:分布式锁(多实例部署场景)
如果服务是多实例运行,可借助Redis实现分布式锁,确保同一事件只有一个请求在处理:
import redis from django.conf import settings r = redis.Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=0) def webhook_view(request): payload = json.loads(request.body) event_id = payload.get('id') event_type = payload.get('type', '') logger.info(f'type: {event_type} event_id: {event_id}') # 用事件ID作为锁键,设置10秒过期时间(足够处理事件) lock_key = f"webhook_event:{event_id}" if not r.set(lock_key, "processing", nx=True, ex=10): logger.info(f'{event_id} is being processed or already exists') return HttpResponse(status=200) try: if Event.objects.filter(id=event_id).exists(): logger.info(f'{event_id} already exists') return HttpResponse(status=200) Event.objects.create(id=event_id, created_at=timezone.now(), data=json.dumps(payload)) # 处理业务逻辑 finally: # 释放锁 r.delete(lock_key) return HttpResponse(status=200)
另外,无论用哪种方案,都要确保Webhook视图尽快返回200响应,不要在视图中阻塞处理业务逻辑(比如用户授权),可以把业务逻辑放到异步任务(如Celery)中执行,视图只负责验证和去重,避免因响应超时触发Stripe重试。
内容的提问来源于stack exchange,提问作者Nori

