Supabase保存Calendly webhook数据时竞态条件处理方案
Calendly Webhook重调度竞态问题解决方案
问题现象
当前对接Calendly Webhook时,订阅了两类事件:
invitee.created:会话创建invitee.canceled:会话取消
接收推送后自动更新数据库内的预约记录,单独创建、单独取消的常规场景下逻辑运行正常。但用户执行重排日程操作时,Calendly会按「先触发旧会话取消事件→再触发新会话创建事件」的逻辑推送两条通知,现有代码对两个事件做异步并发处理、无顺序控制,会出现竞态条件,导致最终数据库状态不符合预期。
现有业务代码
// data is what I get from the webhook which is // an object containing information about the session // booked or cancellled. It looks something like this: { created_at: '2022-07-06', created_by: 'https://api.calendly.com/users/3a1c8403', event: 'invitee.created', payload: { cancel_url: 'https://calendly.com/cancellations/058cb81f', created_at: '2022-07-06', email: 'xx@xxx.com', event: 'https://api.calendly.com/scheduled_events/7867fa63', first_name: null, last_name: null, name: 'xxxx', new_invitee: null, no_show: null, old_invitee: 'https://api.calendly.com/scheduled_events//invitees/283ba2b1-da59-4b2c', payment: null, questions_and_answers: [ [Object] ], reconfirmation: null, reschedule_url: 'https://calendly.com/reschedulings/058cb81f', rescheduled: false, routing_form_submission: null, status: 'active', text_reminder_number: null, timezone: 'Asia', tracking: { utm_campaign: null, utm_source: '158', utm_medium: null, utm_content: null, utm_term: null, salesforce_uuid: null }, updated_at: '2022-07-06T16:53:23.191059Z', uri: 'https://api.calendly.com/scheduled_events/7867f' } } // then my logic: const getSessionData = () => { axios .request({ method: 'GET', url: `http://xxxx`, headers: { 'Content-Type': 'application/json', Authorization: process.env.CALENDLY_API_KEY, }, }) export default async (req, res) => { const body = (await buffer(req)).toString() const data = body ? JSON.parse(body) : null if(data) { switch (data?.event) { case 'invitee.canceled': const processCancellation = async () => { const cancelPromise = getSessionData(event) const cancelSessionData = await cancelPromise // update database with sessionData await supabase .from('booking') .update({ // cancelSessionData... }) .eq('id', sessionId) } processCancellation() break case 'invitee.created': const processBooking = async () => { const createPromise = getSessionData(event) const createSessionData = await createPromise await supabase .from('booking') .update({ // createSessionData... }) .eq('id', sessionId) } processBooking() break default: console.log( `Sorry, no data!` ) } } res.send({ received: true }) }
可选解决方案
方案1:基于事件时间戳实现乐观锁(优先推荐,改造成本最低)
Calendly推送的所有事件payload中都自带updated_at时间戳,重调度场景下取消事件的时间戳必然早于新建事件的时间戳,基于这个特性做乐观锁即可解决乱序问题:
- 给
booking表新增last_event_time字段,用于存储最后一次更新该记录的事件时间戳 - 每次执行更新操作时,增加判断条件:只有当前事件的
updated_at晚于数据库中存储的last_event_time时,才允许更新 - 更新时同步将
last_event_time字段的值设为当前事件的updated_at
核心更新逻辑示例:
// 取消事件处理逻辑,创建事件逻辑完全一致 await supabase .from('booking') .update({ // 原有需要更新的业务字段 last_event_time: data.payload.updated_at }) .eq('id', sessionId) .lt('last_event_time', data.payload.updated_at) // 仅更新时间戳更早的记录,拦截乱序请求
该方案完全在数据库层面实现拦截,不需要修改webhook响应逻辑、不需要引入额外组件,哪怕取消事件因为网络延迟晚于创建事件到达,也会因为时间戳更旧被拦截,不会覆盖新预约的有效状态。
方案2:识别重调度关联字段,跳过无效取消
Calendly的事件payload本身自带重调度关联标识:
- 重调度触发的
invitee.created事件中,old_invitee字段不为空,指向被替换的旧预约资源地址 - 对应的旧预约触发的
invitee.canceled事件中,new_invitee字段不为空,指向替换后的新预约资源地址
处理取消事件时增加判断:如果当前取消事件的payload中new_invitee字段有值,说明本次取消是重调度触发的,直接跳过本次取消的数据库更新操作,等待后续创建事件落地即可。
该方案逻辑简单,但需要做好新旧预约的ID映射匹配,避免误拦截用户主动触发的正常取消事件。
方案3:同预约ID事件串行处理(适合复杂业务场景)
如果后续需要对接的webhook事件类型更多、业务逻辑更复杂,可以引入分布式锁或者消息队列:
- 以预约ID作为锁键,同一个预约的事件处理必须拿到锁才能执行
- 或者用消息队列按预约ID做哈希路由,保证同一个预约的所有事件被同一个消费者串行处理
该方案从执行机制上根源避免并发问题,但需要引入额外中间件,改造成本更高,适合业务规模较大的场景。
现有代码的额外问题
- 目前
getSessionData函数没有返回axios请求实例,调用后拿到的永远是undefined,无法正确获取Calendly接口返回的数据,需要补上return关键字。 - 分支中调用
processCancellation()和processBooking()时没有加await,触发异步函数后直接返回了webhook响应,如果函数执行过程中报错会直接静默失败,无法感知异常,也会导致请求上下文提前释放引发不可预期的问题。
内容的提问来源于stack exchange,提问作者Omar
相关产品推荐
相关产品推荐

