You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Laravel中union配合lockForUpdate无法锁定ticket_entries行的问题

问题:并发场景下无法锁定ticket_entries行导致重复分配门票

我已经在代码中使用DB::transaction,先通过lockForUpdate锁定ticket_types数据,确保用户获取相同ticket_type时排队执行,但发现无法锁定$ticket_entries对应的行:第一个用户提交事务并更新ticket_entries的user_id等字段后,第二个用户仍能获取到同一条ticket_entries记录,且其user_id为null。我推测问题出在锁定行之前就已查询到了记录。

我的代码如下:

DB::beginTransaction();
$user = $request->user();

$ticket_types = OnlineTicketingTicketType::where('online_ticketing_event_id', $request->event_id)
    ->where('status', 1)
    ->whereIn('id', $request->ticket_types_array)
    ->lockForUpdate()
    ->get();

$ticket_entries = OnlineTicketingTicketEntry::where('online_ticketing_event_id', $request->event_id)
    ->where("online_ticketing_ticket_type_id", $ticket_types[0]->id)
    ->where(function ($query) {
        $query->where("status", "available")
            ->orWhere(function ($query) {
                $query->where("status", "lock for payment")
                    ->Where('expired_date', '<', now());
            });
    })
    ->take($request->ticket_types[$ticket_types[0]->id]);

for($i = 1; $i < count($request->ticket_types_array); $i++) {
    $ticket_entries = $ticket_entries->union(
        OnlineTicketingTicketEntry::where('online_ticketing_event_id', $request->event_id)
            ->where("online_ticketing_ticket_type_id", $ticket_types[$i]->id)
            ->where(function ($query) {
                $query->where("status", "available")
                    ->orWhere(function ($query) {
                        $query->where("status", "lock for payment")
                            ->Where('expired_date', '<', now());
                    });
            })
            ->take($request->ticket_types[$ticket_types[$i]->id])
    );
}

$tickets = $ticket_entries->lockForUpdate()->get();

OnlineTicketingTicketEntry::whereIn('id', $tickets->pluck('id')->toArray())
    ->update([
        'user_id' => $user->id,
        'status' => "lock for payment",
        'expired_date' => now()->addMinutes(10),
    ]);

DB::commit();

原本预期流程:用户A锁定ticket_type→查询并锁定不同ticket_type的所有ticket_entry→更新条目→提交事务;用户B执行相同操作时,会锁定相同ticket_type,但查询并锁定的是同类型但不同entry_id的ticket_entry。但实际所有用户都获取到了相同的ticket_entries。


解决方案

核心问题分析

  • Union查询的锁机制缺陷:Laravel中union组合的查询,追加lockForUpdate时无法确保锁覆盖所有子查询的行。MySQL对union查询的行锁支持有限,可能导致部分目标行未被锁定。
  • 查询与锁定的时机差:在构建$ticket_entries过程中,子查询的过滤条件(如available状态、过期时间)已确定,并发场景下,第一个用户更新行状态后,第二个用户的查询在加锁前可能读取到旧数据(可重复读隔离级别下的快照读)。

修复方案

方案一:拆分查询,逐个锁定票型对应的条目

放弃union,循环处理每种票型,查询时直接加锁,确保锁立即生效:

DB::beginTransaction();
$user = $request->user();

// 先锁定票型,强制并发请求排队
$ticket_types = OnlineTicketingTicketType::where('online_ticketing_event_id', $request->event_id)
    ->where('status', 1)
    ->whereIn('id', $request->ticket_types_array)
    ->lockForUpdate()
    ->get();

$selectedTicketIds = [];

foreach ($ticket_types as $ticketType) {
    $takeCount = $request->ticket_types[$ticketType->id];
    
    // 查询时直接加锁,避免读取旧数据
    $entries = OnlineTicketingTicketEntry::where('online_ticketing_event_id', $request->event_id)
        ->where("online_ticketing_ticket_type_id", $ticketType->id)
        ->where(function ($query) {
            $query->where("status", "available")
                ->orWhere(function ($query) {
                    $query->where("status", "lock for payment")
                        ->Where('expired_date', '<', now());
                });
        })
        ->lockForUpdate()
        ->take($takeCount)
        ->get();
    
    $selectedTicketIds = array_merge($selectedTicketIds, $entries->pluck('id')->toArray());
}

// 更新选中的门票条目
OnlineTicketingTicketEntry::whereIn('id', $selectedTicketIds)
    ->update([
        'user_id' => $user->id,
        'status' => "lock for payment",
        'expired_date' => now()->addMinutes(10),
    ]);

DB::commit();

方案二:原生SQL构建带锁的Union查询

如果必须使用union,直接拼接SQL确保锁覆盖所有行:

DB::beginTransaction();
$user = $request->user();

$ticket_types = OnlineTicketingTicketType::where('online_ticketing_event_id', $request->event_id)
    ->where('status', 1)
    ->whereIn('id', $request->ticket_types_array)
    ->lockForUpdate()
    ->get();

// 构建各票型的子查询SQL
$subQueries = [];
foreach ($ticket_types as $ticketType) {
    $takeCount = $request->ticket_types[$ticketType->id];
    $subQuery = OnlineTicketingTicketEntry::where('online_ticketing_event_id', $request->event_id)
        ->where("online_ticketing_ticket_type_id", $ticketType->id)
        ->where(function ($query) {
            $query->where("status", "available")
                ->orWhere(function ($query) {
                    $query->where("status", "lock for payment")
                        ->Where('expired_date', '<', now());
                });
        })
        ->take($takeCount)
        ->toSql();
    $subQueries[] = "($subQuery)";
}

// 拼接Union查询并加锁
$unionSql = implode(' UNION ', $subQueries) . ' FOR UPDATE';
$tickets = DB::select($unionSql);

// 提取ID并更新
$selectedTicketIds = array_column($tickets, 'id');
OnlineTicketingTicketEntry::whereIn('id', $selectedTicketIds)
    ->update([
        'user_id' => $user->id,
        'status' => "lock for payment",
        'expired_date' => now()->addMinutes(10),
    ]);

DB::commit();

额外优化建议

  • 给online_ticketing_ticket_entry表添加复合索引:(online_ticketing_event_id, online_ticketing_ticket_type_id, status, expired_date),提升查询和锁的效率。
  • 检查实际获取的条目数量是否等于请求数量,避免部分票型无可用票时继续执行。
  • 若需要更及时的状态读取,可临时将数据库隔离级别改为读已提交(需权衡一致性)。

内容的提问来源于stack exchange,提问作者lee tat chiu

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 14:47:41