Apache+MySQL环境下并发请求引发票务超卖问题的技术问询
解决并发票务超卖问题的方案
你的问题是典型的并发竞态条件导致的——当多个请求同时发起购票时,它们都会先执行那串复杂的SELECT查询,此时还没有任何请求把票数写入block表,所以所有请求读到的total_blocked都是相同的旧值,都判断自己的请求符合条件,最终都写入block表,导致总票数超过限制。
下面给你几个可行的解决方案,按推荐优先级排序:
方案一:数据库事务+行级锁(最稳妥)
MySQL的InnoDB引擎支持行级锁,我们可以在查询时加上FOR UPDATE语句锁定目标schedule行,确保同一时间只有一个请求能读取并修改这条数据,从根源避免竞态。
修改后的代码示例:
// 开启数据库事务 $this->db->trans_start(); // 关键:用FOR UPDATE锁定查询到的schedule行,其他请求会等待锁释放后再读取 $res = $this->db->query("SELECT IFNULL(sum(tb.tickets),0) as total_blocked, ttb.tickets as totalUserBlock, e.max_tickets,IFNULL(awm.tickets,0) as user_allowed_tickets, IFNULL(SUM(me.tickets),0) as paid_tickets, IFNULL(sum(me.free_tickets),0) as freetix, s.tickets as alocated_tickets, s.purchased as tickets_purchased, s.start_date,s.start_time FROM schedule s left JOIN event e on e.id=s.event_id left JOIN myevent me on me.schedule=s.id AND me.uid = '" . $this->user_id . "' left JOIN ticket_blocked ttb ON ttb.sch_id=s.id AND ttb.user_id = '" . $this->user_id . "' AND ttb.status = 1 left JOIN ticket_blocked tb ON tb.sch_id=s.id AND tb.status = 1 left JOIN allowmore awm on awm.schedule=s.id AND awm.uid = '" . $this->user_id . "' WHERE s.id='" . $id . "' FOR UPDATE")->row_array(); // 修正原有代码的重复判断问题:取剩余票数和用户限额的较小值 $total_purchased_tickets = $res['freetix'] + $res['paid_tickets'] + $res['totalUserBlock']; $limit = $res['user_allowed_tickets'] > 0 ? $res['user_allowed_tickets'] : $res['max_tickets']; $total_remain_tickets = $res['alocated_tickets'] - ($res['total_blocked'] + $res['tickets_purchased']); $limit -= $total_purchased_tickets; $tickets = min($total_remain_tickets, $limit); $allow_booking = false; if ($post['requested_tickets'] <= $tickets) { // 允许购票:将票数写入block表(这一步也要放在事务内) $this->db->insert('ticket_blocked', [ 'sch_id' => $id, 'user_id' => $this->user_id, 'tickets' => $post['requested_tickets'], 'status' => 1 // 补充其他必要字段 ]); $allow_booking = true; } // 提交事务 $this->db->trans_complete(); // 检查事务是否成功 if ($this->db->trans_status() === FALSE) { return false; } return $allow_booking;
为什么有效?
FOR UPDATE会锁定查询到的schedule记录,其他请求必须等待当前事务提交/回滚后才能读取这条数据,确保每个请求都基于最新的block表状态进行判断,不会出现“同时读到旧数据”的情况。
方案二:乐观锁(适合高并发、冲突少的场景)
如果担心行级锁影响性能,可以用乐观锁机制:给schedule表增加一个version字段,每次更新时验证版本号是否一致,以此判断数据是否被其他请求修改过。
大致流程:
- 查询时同时获取
schedule的version和剩余票数 - 计算是否允许购票
- 插入
block表的同时,尝试更新schedule的version(仅当版本号和查询时一致才成功) - 如果更新失败,说明有其他请求先修改了数据,返回购票失败或引导用户重试
这种方式不需要加锁,性能更好,但需要处理冲突重试的逻辑。
方案三:分布式锁(多服务器部署场景)
如果你的应用部署在多台服务器上,数据库行级锁可能无法跨服务器生效,这时可以用Redis等实现分布式锁:
// 示例:用Redis获取分布式锁,锁的key绑定到具体的schedule ID $lock_key = "ticket_booking_lock_" . $id; // 设置锁:NX表示仅当key不存在时才成功,EX设置10秒超时避免死锁 $lock_acquired = $redis->set($lock_key, uniqid(), ['NX', 'EX' => 10]); if (!$lock_acquired) { // 获取锁失败,返回“当前购票人数过多,请稍后再试” return false; } try { // 在这里执行原有的查询、判断、写入block表逻辑 // ... } finally { // 无论成功失败,都释放锁 $redis->del($lock_key); }
注意点:
- 锁的超时时间要设置合理,避免因请求异常导致锁无法释放
- 可以结合重试机制,让用户在获取锁失败时自动重试几次
额外注意:修复原有代码的逻辑错误
你的代码里有一行重复的判断:
$tickets = $total_remain_tickets > $limit ? $total_remain_tickets : $limit;
这会把之前计算的“剩余票数和用户限额的较小值”改成较大值,直接导致判断逻辑失效,这很可能是超卖的诱因之一!一定要删掉这行,改成$tickets = min($total_remain_tickets, $limit);。
内容的提问来源于stack exchange,提问作者presita parmar
相关产品推荐
相关产品推荐

