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
相关产品推荐
相关产品推荐

