如何让PHP票务系统的票号分配机制更健壮?
解决票务系统重复票号问题
看起来你遇到了票务系统里典型的并发竞态条件问题——当批量购买时,循环里的多次调用(或者多用户同时操作)会同时读取可用票号列表:前一次选中的票号还没来得及写入数据库,后一次的freeNumbers查询依然会把这个票号标记为可用,最终导致重复插入。下面是具体的问题分析和解决方案:
一、先做兜底:数据库层面加唯一约束
无论应用层逻辑怎么优化,都要先给数据库加约束,从根源上阻止重复数据插入:
给transaction表的TicketNumber和DrawID添加联合唯一索引,这样即使应用层出现逻辑漏洞,数据库也会直接拒绝重复的票号插入请求。
执行这条SQL语句:
ALTER TABLE transaction ADD UNIQUE KEY unique_ticket_draw (TicketNumber, DrawID);
二、优化票号生成逻辑,解决竞态问题
方案1:数据库事务+行锁(推荐,适合所有场景)
不要在应用层获取全部可用票号,而是直接让数据库帮我们筛选未被占用的票号,同时加锁防止其他请求读取。这样能从数据库层面保证票号的唯一性。
重写你的randomTicket函数:
function randomTicket($drawID){ $connect = new mysqli("localhost", "root", "", "dream"); // 开启事务 $connect->begin_transaction(); try { $maxTickets = totalTickets($drawID); // 用SQL随机筛选未被占用的票号,同时加行锁(FOR UPDATE) $stmt = $connect->prepare(" SELECT all_tickets.num AS TicketNumber FROM ( -- 生成1到maxTickets的所有票号(如果MySQL版本≥8.0,可用递归CTE替代临时表) SELECT 1 AS num UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 -- 如果票号范围更大,建议预先创建一个numbers表存储所有可能的数字 ) AS all_tickets LEFT JOIN transaction t ON t.TicketNumber = all_tickets.num AND t.DrawID = ? WHERE t.TicketNumber IS NULL AND all_tickets.num <= ? ORDER BY RAND() LIMIT 1 FOR UPDATE "); $stmt->bind_param("ii", $drawID, $maxTickets); $stmt->execute(); $result = $stmt->get_result(); $row = $result->fetch_assoc(); if (!$row) { throw new Exception("No available tickets left for this draw"); } $ticketNumber = $row['TicketNumber']; $connect->commit(); return $ticketNumber; } catch (Exception $e) { $connect->rollback(); throw $e; } }
注意:如果你的票号范围很大(比如超过1000),建议预先创建一个
numbers表,存储1到最大可能的票号数字,这样SQL查询效率会更高。
方案2:应用层加全局锁(适合单服务器场景)
如果暂时不想修改数据库逻辑,可以在应用层加锁,确保同一时间只有一个请求能生成票号。比如用文件锁或者Redis锁:
function randomTicket($drawID){ // 用文件锁做示例,生产环境分布式场景推荐用Redis锁 $lockPath = "/tmp/ticket_lock_{$drawID}.lock"; $lockFile = fopen($lockPath, "w+"); // 获取排他锁,确保同一时间只有一个进程能进入逻辑 if (flock($lockFile, LOCK_EX)) { $freeNumbers = freeNumbers($drawID); if (empty($freeNumbers)) { flock($lockFile, LOCK_UN); fclose($lockFile); throw new Exception("No available tickets left"); } $randomIndex = array_rand($freeNumbers, 1); $ticketNumber = $freeNumbers[$randomIndex]; // 释放锁 flock($lockFile, LOCK_UN); fclose($lockFile); return $ticketNumber; } else { fclose($lockFile); // 锁获取失败,短暂重试 usleep(100000); // 等待100毫秒 return randomTicket($drawID); } }
三、修改支付循环的事务逻辑
确保票号生成和插入数据库在同一个事务内完成,避免票号被选中后未插入就被其他请求抢走:
function paymentMade($paymentId, $token, $payerID, $drawID){ $connect = new mysqli("localhost", "root", "", "dream"); $connect->begin_transaction(); try { // 在事务内生成票号,确保锁的有效性 $ticketNumber = randomTicket($drawID); // 插入票务记录 $stmt = $connect->prepare("INSERT INTO transaction (DrawID, TicketNumber, PaymentId, Token, PayerID) VALUES (?, ?, ?, ?, ?)"); $stmt->bind_param("iisss", $drawID, $ticketNumber, $paymentId, $token, $payerID); $stmt->execute(); $connect->commit(); return "Success! Assigned ticket number: {$ticketNumber}"; } catch (Exception $e) { $connect->rollback(); return "Error: " . $e->getMessage(); } }
总结
最可靠的方案是数据库联合唯一约束+事务内锁查询,既保证了数据的一致性,又能高效处理并发场景。如果是单服务器部署,应用层锁也能临时解决问题,但分布式环境下还是推荐数据库层面的解决方案。
内容的提问来源于stack exchange,提问作者user9304280
相关产品推荐
相关产品推荐

