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

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字段,每次更新时验证版本号是否一致,以此判断数据是否被其他请求修改过。

大致流程:

  1. 查询时同时获取schedule的version和剩余票数
  2. 计算是否允许购票
  3. 插入block表的同时,尝试更新schedule的version(仅当版本号和查询时一致才成功)
  4. 如果更新失败,说明有其他请求先修改了数据,返回购票失败或引导用户重试

这种方式不需要加锁,性能更好,但需要处理冲突重试的逻辑。

方案三:分布式锁(多服务器部署场景)

如果你的应用部署在多台服务器上,数据库行级锁可能无法跨服务器生效,这时可以用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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.05.14 08:45:30