返回

FullCalendar事件移动重复:数据库排查与解决方案

javascript

FullCalendar 事件移动时数据库重复问题排查与解决

使用 FullCalendar 时,事件拖拽至新的日期/时间,有时会导致数据库中出现重复条目,即使在代码中加入了防止重复的检查逻辑,这种情况依旧可能发生。本文深入分析该问题,并提出切实可行的解决方案。

问题分析

问题的根源往往在于对数据库操作的并发性和时序的理解不足。即便在服务端加入了防止重复的校验,高并发或客户端操作的延迟依然可能导致问题发生。简单来说,以下情境都可能造成事件重复:

  1. 竞争条件 (Race Condition): 当两个或多个客户端几乎同时拖动同一个事件,都尝试更新数据库。 假设两个请求在数据库检查和保存更新之间重叠。第一个请求检查后,认为没有重复,继续保存。第二个请求执行相同的操作,最终导致两个具有相同 NumDocAno 的条目被保存。

  2. 前端事件触发时序: eventDrop 回调在用户拖拽结束时立即触发。如果网络延迟或请求队列积压,实际请求发送和处理顺序与拖动操作顺序存在差异,同样导致并发。

  3. 数据库锁的利用不足: 某些情况下,事务锁的力度不足或者使用不正确也可能出现问题,保证更新时数据库的一致性至关重要。

  4. 前端状态管理 : 事件移动时前端可能没有立刻同步 fullcalendar 组件内部状态。或者客户端对 event.id 的理解与后端数据逻辑存在不一致。

解决方案

方案一:利用数据库事务的串行化级别

数据库事务具有不同的隔离级别,串行化隔离级别 (Serializable) 能有效防止上述竞争条件的出现。 其强制要求事务完全按顺序执行,防止数据在事务期间被其他事务所修改。
这种方案主要关注解决数据库并发修改导致的重复事件。

操作步骤:

  1. 修改Django视图:update_delivery_date 函数中设置事务隔离级别。 调整数据获取查询的方式, 获取数据的操作必须在一个事务内部。

代码示例:

from django.db import transaction, connections
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def update_delivery_date(request):
    if request.method == 'POST':
        event_id = request.POST.get('id')
        new_start_date = request.POST.get('new_start_date')
        year = request.POST.get('year')
        
        if not (request.user.groups.filter(name__in=['Expedicao', 'Dir Logistica', 'Logistica']).exists() or request.user.is_superuser):
             return JsonResponse({'success': False, 'error': 'User does not have permission to move events'}, status=403)

        try:
             with transaction.atomic(using='default'):  # 指定使用默认连接
                 with connections['default'].cursor() as cursor:  # 显式获取 cursor

                    # 获取当前数据库连接的隔离级别
                     isolation_level = cursor.connection.isolation_level
                    
                     # 设置事务的隔离级别为可串行化 (可能数据库类型有差异)
                     if isolation_level != 'SERIALIZABLE': # SQL Server特定的设置
                       cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")

                    # 首先,尝试查找并锁定要更新的事件记录
                     cursor.execute(
                         "SELECT * FROM dbo.EncomendasCalendar WHERE NumDoc = %s AND Ano = %s WITH (UPDLOCK);",
                        (event_id,year) 
                     )

                     event_record= cursor.fetchone()
                     if event_record is None:
                         return JsonResponse({'success': False, 'error': 'Event not found'}, status=404)

                     # 检查是否存在重复条目
                     cursor.execute("SELECT COUNT(*) FROM dbo.EncomendasCalendar WHERE NumDoc = %s AND Ano = %s AND DataEntrega != %s;", (event_id, year,new_start_date))
                     duplicate_count = cursor.fetchone()[0]

                     if duplicate_count > 0:
                        print("Duplicate")
                        return JsonResponse({'success': False, 'error': 'An event with the same DocNum and Year already exists'}, status=400)


                    # 检查新日期是否相同
                     if event_record[6].strftime('%Y-%m-%d %H:%M:%S') == new_start_date: # 假设日期字段为第7列,并将其转换为datetime对象后再对比字符串
                        print("same")
                        return JsonResponse({'success': False, 'error': 'The delivery date is already the same as the informed date.'}, status=400)
                               
                    # 更新事件记录
                     cursor.execute(
                          "UPDATE dbo.EncomendasCalendar SET DataEntrega = %s, UpdatedBy_id = %s WHERE NumDoc = %s AND Ano = %s",
                        (new_start_date, request.user.id , event_id, year )
                     )    
                  

        except Exception as e:
            print('Error updating the delivery date:', e)
            return JsonResponse({'success': False, 'error': 'Error updating the delivery date'})
    
        return JsonResponse({'success': True})
    else:
       return JsonResponse({'success': False, 'error': 'Method not allowed'}, status=405)
  • 重要提示: SQL Server的锁机制和隔离级别需要进一步根据数据库版本进行微调,可能与MySQL或者PostgreSQL有所不同。UPDLOCK 可以实现行锁,事务内的后续查询不会受到影响。此外,此修改的目的是为了避免并发写入。数据库类型之间的语法可能需要根据实际情况修改。

方案二:引入客户端唯一标识符和后端幂等性校验

可以生成一个临时UUID, 在前端传递这个值。后端需要做以下操作: 验证唯一标识,只有当数据库中没有这个 UUID 时才能进行更新或者插入操作。 这种方案保证数据不会因为多次提交或者重复点击等情况重复插入或更新,强调请求的幂等性。

操作步骤:

  1. 生成客户端标识:eventDrop 函数中生成 UUID,将 uuid 与其他数据一同发送至后端。
  2. 服务端幂等性校验: 修改 update_delivery_date 函数,利用事务机制对 UUID 进行校验。检查 uuid 是否存在。如不存在,保存或者更新数据并保存 uuid 到额外的表中,保证之后该 uuid 请求不再更新数据。

代码示例(JavaScript, 仅客户端更改):

import { v4 as uuidv4 } from 'uuid';


eventDrop: function (info) {
   
    var eventId = info.event.id;
    var eventYear = info.event.extendedProps.year;
    var newStartDate = info.event.start.toISOString().replace('T', ' ');
    newStartDate = newStartDate.replace('Z', '');
    const uuid = uuidv4();

    var csrftoken = Cookies.get('csrftoken');
    $.ajax({
        type: 'POST',
        url: 'update_delivery_date/',
        data: {
            id: eventId,
            new_start_date: newStartDate,
            year: eventYear,
            uuid: uuid
        },
        beforeSend: function(xhr) {
            xhr.setRequestHeader("X-CSRFToken", csrftoken);
        },
        success: function(response) {

        },
        error: function(xhr, status, error) {
              if (xhr.status === 403) {
               
                 alert('Error: You do not have permission to move this event.');
                info.revert();
           } else {
             
            console.error('Error updating the delivery date:', error);
         }
       }
    });
}

代码示例(Python, 部分视图函数变更,需新建一个表进行幂等验证)

from django.db import transaction, connections
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.db.models import Model, DateTimeField, ForeignKey
from django.conf import settings

# 需要先创建这样的一个 model 保存 uuid 
class ProcessedEvent(Model):
    event_uuid = CharField(max_length=255, primary_key=True)
    processed_at = DateTimeField(auto_now_add=True)


@csrf_exempt
def update_delivery_date(request):
    if request.method == 'POST':
        event_id = request.POST.get('id')
        new_start_date = request.POST.get('new_start_date')
        year = request.POST.get('year')
        uuid = request.POST.get('uuid')
        if not (request.user.groups.filter(name__in=['Expedicao', 'Dir Logistica', 'Logistica']).exists() or request.user.is_superuser):
           return JsonResponse({'success': False, 'error': 'User does not have permission to move events'}, status=403)

        try:
            with transaction.atomic(using='default'):
               with connections['default'].cursor() as cursor:  
                     isolation_level = cursor.connection.isolation_level
                     
                     if isolation_level != 'SERIALIZABLE': # SQL Server特定的设置
                          cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")

                  # 检查UUID是否已经处理过
                     if ProcessedEvent.objects.filter(event_uuid=uuid).exists():
                          return JsonResponse({'success': False, 'error': 'Duplicate request, request processed before'}, status=400) # 防止幂等操作

                     
                     cursor.execute(
                           "SELECT * FROM dbo.EncomendasCalendar WHERE NumDoc = %s AND Ano = %s WITH (UPDLOCK);",
                           (event_id,year) 
                        )
                     
                     event_record = cursor.fetchone()
                     if event_record is None:
                            return JsonResponse({'success': False, 'error': 'Event not found'}, status=404)


                    # 检查是否存在重复条目
                     cursor.execute("SELECT COUNT(*) FROM dbo.EncomendasCalendar WHERE NumDoc = %s AND Ano = %s AND DataEntrega != %s;", (event_id, year,new_start_date))
                     duplicate_count = cursor.fetchone()[0]

                     if duplicate_count > 0:
                        print("Duplicate")
                        return JsonResponse({'success': False, 'error': 'An event with the same DocNum and Year already exists'}, status=400)
                  

                      # 检查新日期是否相同
                     if event_record[6].strftime('%Y-%m-%d %H:%M:%S') == new_start_date: 
                         print("same")
                         return JsonResponse({'success': False, 'error': 'The delivery date is already the same as the informed date.'}, status=400)

                   # 更新事件记录
                     cursor.execute(
                            "UPDATE dbo.EncomendasCalendar SET DataEntrega = %s, UpdatedBy_id = %s WHERE NumDoc = %s AND Ano = %s",
                             (new_start_date, request.user.id , event_id, year )
                         )    

                      # 标记uuid已经处理
                     ProcessedEvent.objects.create(event_uuid=uuid)
                     
        except Exception as e:
            print('Error updating the delivery date:', e)
            return JsonResponse({'success': False, 'error': 'Error updating the delivery date'})
       
        return JsonResponse({'success': True})
    else:
        return JsonResponse({'success': False, 'error': 'Method not allowed'}, status=405)
  • ProcessedEvent 数据库表的定义需要与当前的数据结构匹配。务必根据自己的数据结构调整SELECT,UPDATE的语句。

其他建议

  1. 日志记录: 增加详细的日志记录有助于跟踪问题的产生。将所有 eventDrop 事件,参数传递和执行情况写入日志, 便于日后追查问题。
  2. 请求去重机制: 可以在 ajax 发起前进行简单去重(使用缓存), 如果多次相同操作,只发送第一个请求。 降低并发发生几率,虽然这个方法不能保证最终的完整性。
  3. 前端交互优化: 减少不必要的前端操作, 在操作成功后才移动事件或者更改前端日历的状态。 用户在移动时增加动画或者等待反馈提示。

结论:

解决 FullCalendar 事件重复问题需从数据库事务控制,唯一标识以及服务端幂等校验等多个角度出发,结合自身项目架构特点,进行合理的技术选型和实施。 监控系统和日志系统也可以帮助您在更广的范围内发现问题,更好的优化业务代码。