如何在 Laravel 中防止重复处理 webhook 事件?
结论
建议建立一张 webhook 事件表,将服务商提供的事件 ID 作为幂等键,并通过数据库唯一约束防止重复记录。处理 webhook 时,应把“登记事件”和“修改业务数据”放在同一个数据库事务中。
判断事件是否重复,不能只靠事先查询,而要依靠数据库唯一索引进行原子判重。先执行 exists() 再插入,在并发请求下仍会出现竞争条件。
Laravel 没有一套适用于所有 webhook 服务商的统一幂等机制。队列唯一任务和缓存锁可以辅助控制并发,但不能代替数据库唯一约束。
创建 webhook 事件表
如果应用只接收一个服务商的事件,可以直接为 event_id 添加唯一约束。如果接收多个服务商的事件,建议将 provider + event_id 设为联合唯一键,以免不同服务商碰巧使用相同的 ID。
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider', 50);
$table->string('event_id', 191);
$table->string('type', 100);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->unique(['provider', 'event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('webhook_events');
}
};
event_id 必须来自已经通过身份验证的 webhook 请求,不能直接采用任意客户端提交的值。因此,应先验签,再进行幂等判断。
在事务中原子处理
可以通过 insertOrIgnore() 尝试登记事件。两个相同请求同时执行时,唯一索引只会允许一个请求成功插入。
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
public function webhook(Request $request): JsonResponse
{
// 应先按照支付服务商的规则验证 webhook 签名。
// $this->verifyWebhookSignature($request);
$event = $request->validate([
'id' => ['required', 'string', 'max:191'],
'type' => ['required', 'string', 'max:100'],
'data.order_id' => ['required', 'integer'],
'data.amount' => ['required', 'numeric'],
]);
$processed = DB::transaction(function () use ($event): bool {
$inserted = DB::table('webhook_events')->insertOrIgnore([
'provider' => 'payment-provider',
'event_id' => $event['id'],
'type' => $event['type'],
'payload' => json_encode(
$event,
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
),
'created_at' => now(),
'updated_at' => now(),
]);
if ($inserted === 0) {
return false;
}
if ($event['type'] === 'payment.completed') {
$order = Order::query()
->whereKey($event['data']['order_id'])
->lockForUpdate()
->firstOrFail();
if ($order->status !== 'paid') {
$order->update([
'status' => 'paid',
]);
}
}
DB::table('webhook_events')
->where('provider', 'payment-provider')
->where('event_id', $event['id'])
->update([
'processed_at' => now(),
'updated_at' => now(),
]);
return true;
});
return response()->json([
'success' => true,
'duplicate' => ! $processed,
]);
}
这段代码处理了两个问题:
webhook_events上的唯一索引可以避免同一个事件 ID 被重复处理。lockForUpdate()可以防止不同事件同时修改同一个订单并相互覆盖。
两个相同请求同时到达时,数据库会通过唯一索引处理冲突。只有一个事务能成功插入事件记录。另一个请求的 insertOrIgnore() 会返回 0,不会继续执行业务逻辑。
如果更新订单时抛出异常,整个事务都会回滚,包括刚刚插入的事件记录。服务商之后重试时,这个事件仍可重新处理,不会被误记为已经完成。
为什么不能先查询再插入
下面的写法在并发量较低时可能正常运行,但并不可靠:
if (! WebhookEvent::where('event_id', $event['id'])->exists()) {
WebhookEvent::create([
'event_id' => $event['id'],
]);
// 处理事件
}
两个请求可能同时执行完 exists() 查询,并且都得到“不存在”的结果,随后一起进入处理逻辑。
即使保留这项查询,也必须添加数据库唯一索引,并妥善处理唯一键冲突。既然最终还是要依靠唯一索引,通常不如直接使用原子插入,也能避免预查询带来的误解。
firstOrCreate() 和 updateOrCreate() 同样不能代替数据库约束。它们能否安全应对并发竞争,仍取决于表上有没有唯一索引,以及代码是否正确处理了冲突。
是否可以使用缓存锁
Laravel 的原子锁可以减少短时间内重复执行的请求,例如:
Cache::lock("webhook:{$eventId}", 30)->block(5, function () {
// 处理事件
});
但缓存锁通常不适合充当唯一的幂等保障:
- 锁过期后,同一个事件仍可能再次到达。
- Redis 或其他缓存被清理或发生故障切换时,历史判重信息可能丢失。
- 如果处理时间超过锁期限,还需要处理锁续期。
- 锁只能阻止请求同时执行,无法证明某个事件过去是否已经处理成功。
缓存锁适合控制并发或减轻数据库压力,长期的幂等状态仍应由数据库唯一索引保存。
使用队列时的处理方式
生产环境中,更常见的做法是让 webhook endpoint 只负责以下工作:
- 验证签名和请求格式。
- 将事件写入
webhook_events,通过唯一索引判重。 - 在事务提交后派发队列任务。
- 尽快向服务商返回
2xx。 - 由队列任务处理订单并更新
processed_at。
Laravel 的唯一队列任务可以减少相同任务重复入队,但其实现依赖缓存锁,不能代替事件表上的唯一约束。
如果事件处理涉及发送邮件、调用外部 API、退款等数据库之外的副作用,单靠数据库事务无法保证幂等。外部调用成功后,数据库事务仍可能失败并回滚,重试时便会再次调用外部系统。遇到这种情况,应采用 inbox/outbox 模式,或者将同一个幂等键传给支持幂等请求的外部服务。
其他注意事项
- 收到重复事件时通常应返回成功响应,例如
200,否则服务商可能不断重试。 - 只有事件确实处理成功后,才能设置
processed_at。 - 遇到临时故障时,应返回非
2xx响应或抛出异常,让服务商或队列稍后重试。 - 幂等键最好包含服务商、账户或租户范围,具体范围取决于服务商对事件 ID 唯一性的保证。
- 按事件 ID 判重只能阻止同一事件重复执行,不能处理不同事件乱序到达的问题。订单状态更新还需要遵循业务状态机,必要时比较服务商提供的事件版本或发生时间。
- 金额不应使用二进制浮点数进行精确比较。支付场景应优先使用以最小货币单位表示的整数,或采用数据库的定点小数类型。
- 某些数据库中的
INSERT IGNORE可能忽略唯一键冲突之外的错误。应明确设置表约束和写入字段。如果需要严格区分错误类型,可以改用普通插入,只捕获相应数据库的唯一键冲突。
备注:内容仅供参考。
