
做火车售票系统这类业务第一反应通常是先挑框架。我当时接手这个项目时代码库里躺着两套 PHP 框架老的管理后台跑在 ThinkPHP 上新的对外 API 和核心交易链路已经切到了 Laravel。这种“双框架共存”的状态听起来有点别扭但在真实团队里其实很常见——历史代码不能丢新业务又想用更好的工具。这篇文章就围绕这套基于 ThinkPHP 和 Laravel 的火车售票系统从需求拆解、数据库设计、余票库存、下单支付、后台管理、部署运行到安全排查完整过一遍设计和实现过程。不管你是正在做毕业设计的学生还是刚入行的 PHP 开发又或者接手了类似“新旧系统并行”项目的同学这篇文章里提到的表结构、扣库存逻辑、接口对接方式、常见报错排查都是可以直接抄作业的。我尽量把“为什么这么做”讲清楚而不是只丢一段代码毕竟你也不想抄完发现自己完全没法维护。1. 双框架并存ThinkPHP 与 Laravel 在售票系统中的分工1.1 一个项目里为什么要同时用两个 PHP 框架这套系统最早是用 ThinkPHP 写的后台管理、车次维护、订单查询这些内部功能都已经跑了好几年。后来业务要对外开放 App 和小程序端需要更灵活的 API 设计、队列处理、事件驱动和更完善的中间件机制Laravel 明显更合适。但把整个后台重写成 Laravel 成本太高风险也大所以最终采用了“双轨并行”的架构ThinkPHP 负责管理端Laravel 负责 API 端。两个框架之间不是物理隔离而是通过 HTTP 接口通信。管理后台里点击“发布车次”时ThinkPHP 会调用 Laravel 的 API把车次信息同步到核心交易库用户端的购票、余票查询、支付回调全部走 Laravel。这样既保住了老系统的稳定性又让新业务享受到 Laravel 的工具链。双框架的关键是边界要清晰不能这边请求写到一半又去直接操作对方的表否则出了问题很难排查。我在项目里还写了一份接口文档把两个框架之间的调用关系固定下来新增功能时先看文档再动手避免越权访问对方的数据库。1.2 核心需求拆解与模块边界划分火车售票系统的功能看起来简单真做起来很琐碎。我按用户和管理员两个视角拆了一下核心需求大致是这样的角色功能说明用户车次查询按出发地、目的地、日期查车次与余票用户下单购票选择车次、乘客、席别生成订单用户在线支付对接支付渠道回调后出票用户退票退款按规则退票释放库存原路退款管理员车次管理车次、站点、开点、到点、票价维护管理员余票监控查看各车次各日期的销售情况管理员订单管理订单查询、异常处理、导出报表模块边界上我建议把“库存服务”单独拎出来因为它被查询、下单、退票、超时释放这几条链路共同使用。再往下拆分就是用户服务、车次服务、订单服务、支付服务、后台管理。Laravel 里的路由和中间件正好能按这个边界组织比如/api/train/search、/api/order/create、/api/order/pay/notify对应的控制器和 Service 类也按模块分目录后续加功能不会把代码越搞越乱。补充一点选型理由ThinkPHP 自带的分页、验证器、模板引擎做管理后台效率很高Laravel 的 Eloquent、队列、事件、中间件、测试工具对核心交易链路更友好。不是说一个框架比另一个强而是要看场景。我们后来在压力测试中也验证过同样的查询在 Laravel 中通过 query builder 和缓存层可以压到很低的响应时间ThinkPHP 管理后台因为流量不大反而不用过度优化。2. 数据库设计与核心表结构2.1 用户、车次、订单等表怎么设计这个系统里最核心的表是车次、站点、车次停靠、订单、支付记录。刚开始图省事我把好几个表合成一张大表结果一到高峰期查询就慢得离谱。后来老老实实拆表加索引数据模型清晰多了。下面是几张大表的关键字段stations站点表id、name、city、pinyin、status。trains车次表id、train_no、train_type、start_station_id、end_station_id、departure_time、arrival_time、status。train_stops车次停靠表id、train_id、station_id、stop_order、arrival_time、departure_time、stop_duration。orders订单表id、order_no、user_id、train_id、travel_date、total_amount、status、created_at、expired_at。order_items订单明细表id、order_id、passenger_name、id_card、seat_type、carriage_no、seat_no、fare。payments支付记录表id、order_id、pay_no、channel、amount、status、paid_at。有几个细节很重要。订单号不要用数据库自增 id而是生成唯一业务编号比如“日期随机数”方便后续对账和支付回调时定位。订单状态字段建议用整数或简短字符串统一约定0 待支付、1 已支付、2 已出票、3 已退票、4 已取消代码里写成常量不要散落在业务逻辑里。所有涉及钱的表都要记录金额单位我习惯用“分”存储避免浮点数误差。还有乘客身份证号这类敏感信息要做脱敏和权限控制日志里不要打印完整号码。上线后有一次排查问题需要查用户信息我才体会到这些细节有多值钱——不然日志一打等于把用户隐私直接贴脸上。2.2 余票库存的数据模型与一致性方案火车票的库存模型比普通商品库存复杂因为一条车次包含多个区间。比如 T1 次列车从北京到上海中间经停济南一个乘客买北京到上海占用的实际上是北京-济南和济南-上海两段库存。所以设计余票表时不能只记“整列车剩余多少张”而要把库存粒度放到区间上。我用的表结构是train_stocksid、train_id、travel_date、segment_from、segment_to、seat_type、total_stock、sold_count、version。查询某段余票时看该区间 sold_count 是否已达到 total_stock。下单扣库存时把起点站到终点站之间经过的所有库存区间都减一。这里必须保证原子性我推荐用 Redis 做热数据的原子扣减数据库表作为最终准确数据。Redis 扣减用DECR命令如果 key 的值小于 0 就说明卖超了需要回滚数据库里再用事务更新 sold_count 做兜底。为什么不只依赖数据库行锁因为余票查询是高并发读数据库行锁在热点车次上容易变成瓶颈。Redis 的内存操作可以扛住绝大多数查询和扣减异步再把已售数量同步回数据库。同步可以用 Laravel 队列写一个定时对账任务每 30 秒把 Redis 里的销量同步到 MySQL同时记录版本号用乐观锁防止并发更新丢失。压测时我们发现这种“Redis 扣减 数据库兜底 定时对账”的方案在突发瞬时流量下也能保持数据一致。3. 核心业务逻辑实现Laravel API 部分3.1 车次与余票查询的实现车次查询是用户接触最多的功能。我按“出发城市、到达城市、日期”的条件搜索先查trains和train_stops关联出符合条件的车次再批量读取对应日期的余票。代码看起来大概是这样的$trains Train::with([stops function ($query) use ($from, $to) { $query-whereIn(station_id, [$from, $to]) -orderBy(stop_order); }]) -whereHas(stops, function ($query) use ($from) { $query-where(station_id, $from); }) -whereHas(stops, function ($query) use ($to) { $query-where(station_id, $to) -where(stop_order, , $fromOrder); }) -get();这段查询的写法不是重点重点是加索引。train_stops表里train_id、station_id、stop_order都要有联合索引不然车次一多必然慢。余票数据我直接用 Redis 批量取key 设计成train:stock:{date}:{train_id}:{from}:{to}:{seat_type}值存剩余张数。查询接口先查 Redis没有缓存或缓存过期再查 MySQL回填缓存时设置 10 秒过期防止热点数据不一致太久。这里有个小坑同一车次不同日期是不同 key如果不加日期的参数会出现“查到了车次但无票”的乌龙。我一开始就漏了日期维度结果测试人员反馈说“明天显示无票、后天又显示有票”查了半天才发现是 key 没区分日期。所以 key 设计一定要把业务维度写全特别是带日期维度的数据缓存和查询都要把日期作为第一条件不能想当然认为“今天能查的明天也一定一样”。后来我们在测试用例里专门补了跨日查询的场景才把这个问题堵住。余票查询接口还有一个优化点热门车次和冷门车次的缓存策略要分开。冷门车次缓存 30 秒都没问题热门车次最好只缓存 5 秒并且加缓存击穿保护否则一旦缓存过期流量会直接打到数据库上慢查询立马就冒出来。3.2 下单、支付回调与出票流程下单是最容易出 bug 的环节。我的实现顺序是先校验车次和余票然后创建订单同时扣减 Redis 库存再创建支付流水。扣减库存这一段注意用原子操作并且要做好“扣减成功后创建订单失败”的补偿$lockKey lock:train_stock: . $trainId . : . $date . : . $from . : . $to; $lock Redis::lock($lockKey, 10); try { if (!$lock-get()) { return response()-json([message 系统繁忙请稍后重试], 429); } $key train:stock: . $date . : . $trainId . : . $from . : . $to . : . $seatType; $left Redis::decr($key); if ($left 0) { Redis::incr($key); return response()-json([message 余票不足], 422); } DB::transaction(function () use (...) { $order Order::create([...]); $order-items()-create([...]); Payment::create([...]); }); } finally { $lock-release(); }Redis 的DECR命令返回负数时千万要把数量加回去否则就真的“卖多了”。锁用的是 Laravel 自带的 Redis 锁防止极端并发下同一个订单重复扣减。注意锁的粒度越小越好钥匙里一定要有日期和区间不然两个不同区间本来可以并行买结果被锁串行化了。支付完成后支付平台会回调解锁。回调最关键的是验签和幂等。验签失败直接拒绝同一个订单可能收到多次回调所以要先查订单状态如果已经是“已支付”就直接返回成功不要重复处理。确认支付成功后更新订单为已支付然后推一条“出票任务”到消息队列。出票是异步动作如果列车座位需要分配真实座号这步可以在队列里慢慢处理完成后给订单绑定车厢和座号。这样用户支付成功后立刻看到“出票中”体验比同步等很久要好。为什么一定用队列而不是同步出票因为支付平台回调的响应时间有要求如果同步去执行写票、通知、短信等操作超过回调超时时间支付平台会认为失败反复回调反而增加压力。队列还能带来重试机制出票失败可以在消费者里重试三次彻底失败后进入人工处理队列。出票成功之后还要做一件事更新train_stocks表的sold_count把 Redis 里的扣减结果同步给数据库。这个动作可以在出票队列里一并处理避免再开一个定时任务。3.3 退票与退款处理退票的流程是反向下单。先判断当前时间和发车时间的关系在退票规则允许的窗口内才执行。然后在一个数据库事务里做三件事更新订单状态为已退票、把途经区间的库存加回、创建退款记录。退款调用支付平台的退款接口同样要做幂等处理。实现时注意顺序不能乱先释放库存再更新订单最后发起退款。如果退款失败要把订单状态标记为“退款中”同时记录失败原因后续由定时任务扫描重试。退票还有一个小陷阱如果用户买的是联程票或者套餐退其中一段时要检查剩余区间的库存能否满足原订单信息不过在单张车票的场景下直接把区间库存加回就行。这里的核心原则是所有状态变更都放进DB::transaction并且要用乐观锁或唯一约束保证退款订单不能重复。我在refunds表里给order_id加了唯一索引防止同一个订单被发起两次退款。退款还会涉及手续费这个可以单独用一张refund_fees表记录计算规则而不是在代码里写死方便运营后续调整退票费率。4. ThinkPHP 后台管理模块实现4.1 后台框架与权限控制后台沿用 ThinkPHP主要是因为它内置的分页、验证器和模板布局能省不少事。我把后台分成站点管理、车次管理、停靠管理、订单管理、用户管理、权限管理这几个模块。权限控制用简单的 RBAC管理员表、角色表、菜单表和它们的关联表管理员登录后从角色查菜单再逐项校验按钮权限。ThinkPHP 里做权限中间件很简单比如在app/middleware.php注册一个全局中间件在进入控制器之前校验当前用户的菜单权限。校验不通过时返回统一 JSON 或者跳转到无权限页面。用户密码不要用 md5 或者明文我用 password_hash 加密校验用 password_verify。登录状态用 session配合 Session 中间件后台保持七天登录可以直接延长 session 过期时间。权限粒度建议至少到“操作方法”一级。比如订单列表可以看但退款按钮只有财务角色能用。我在权限表里存的是“控制器/方法”的字符串比如order/refund然后用中间件比对。这样后续增加操作只需在菜单表里加一行记录不用改代码。权限这东西做得太粗运营同事会时不时误操作做得太细又会让开发累死。所以我会把按钮权限和菜单权限分开菜单管“能不能看到这个页面”按钮管“点了有没有效果”。4.2 车次、票价与订单管理车次管理的核心是 CRUD 加数据校验。添加车次时需要检查起点站和终点站不能相同、时间上到达时间必须晚于出发时间、停靠站顺序不能重复。ThinkPHP 的验证器可以直接在控制器里做$validate new TrainValidate(); if (!$validate-check($data)) { return json([code 1, msg $validate-getError()]); }校验通过后写入 trains 表和 train_stops 表同时调用 Laravel 的接口把车次同步到 API 端。这里我加了一个同步标记字段sync_status同步成功才置为正常否则后台列表里会明显标红方便运营发现问题。同步失败还会写一条告警记录通过后台通知或邮件提醒管理员处理。订单管理页面主要处理搜索、导出和异常退款。搜索条件包括日期段、车次号、订单号、手机号。写 SQL 时注意不要用%订单号%这种全模糊查询会全表扫。订单号一般可以精确匹配日期范围走索引。导出用fputcsv或 PHPExcel数据量大时直接分块读防止内存溢出。我在这个模块里踩过的坑是导出几十万条数据时把 PHP 内存撑爆后来改成每次读 5000 条写入临时文件再合并下载。如果只是给运营提供几万条数据这种方式完全够用没必要上大数据组件。5. 双框架部署与 ThinkPHP 项目运行5.1 项目目录与接口对接这套系统的源码目录我分成两个根项目api放 Laraveladmin放 ThinkPHP。线上用 Nginx 同一个域名下通过不同路径分发或者直接用两个子域名。同一域名下配置需要注意server { listen 80; server_name train.example.com; location /admin { alias /www/wwwroot/train/admin/public/; try_files $uri $uri/ /admin/index.php?s$uri$args; } location /api { alias /www/wwwroot/train/api/public/; try_files $uri $uri/ /api/index.php?$query_string; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $request_filename; include fastcgi_params; } }实际生产环境我更推荐两个独立的 server 块甚至两个 PHP 进程池防止一边挂了把另一边拖死。双框架之间调用 API 时我用了一个简单的签名机制ThinkPHP 端发起请求时带app_id、timestamp、signsign 是请求参数加上密钥按约定排序后做 MD5。Laravel 这边写一个中间件验签同时检查时间戳误差不超过 10 分钟防止重放攻击。这个机制不复杂但足以拦截掉大部分乱来的请求。接口对接还要考虑日志。ThinkPHP 调用 Laravel 接口时我会在两边各写一条请求日志记录请求参数、返回结果、耗时。这样一旦后台操作没有生效直接查日志就能看到是请求没发出去还是接口返回异常。分布式系统最怕两边日志孤岛能把一条请求从入口到出口串起来排查效率能提升很多。5.2 ThinkPHP 项目运行常见报错与解决很多同学拿到一个 ThinkPHP 项目本地怎么都跑不起来其实问题集中在几个地方。第一是权限。ThinkPHP 运行时会往runtime目录写缓存、日志。Linux 下经常遇到runtime 目录没有写权限的报错直接执行chmod -R 777 runtime本地调试图省事可以 777生产环境建议把 owner 改成 PHP-FPM 进程用户不要真的用 777安全风险太大。第二是伪静态。Nginx 下如果访问首页正常跳转到其他路由就 404多半是伪静态规则没配好。ThinkPHP 的 Nginx 伪静态规则是if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s$1 last; }Apache 环境则需要开启 mod_rewrite 并放.htaccess。第三是缺 PHP 扩展。比如fileinfo、opcache、pdo_mysql缺少时有些功能会静默失败。用php -m检查缺哪个装哪个即可。用命令行调试 ThinkPHP 也会很顺手。进入项目根目录后执行php think run可以启动内置开发服务器适合快速验证路由是否正常。如果发现某个接口报 500打开runtime/log/年月日.log看具体报错比看浏览器空白页高效得多。再分享一个实际场景。有个同事接手项目后一直说“后台登录不进去”本地检查了一下午最后发现是.env数据库密码配错但数据库连接失败后 ThinkPHP 把异常吞掉了直接 500。后来我让他把app_debug打开报错马上显示出来。所以排查这些问题时第一步永远是开 debug、看日志而不是猜。6. 安全防护与排查技巧实录6.1 Laravel 与 ThinkPHP 的安全基线这个项目上线前团队还把安全加固仔细过了一遍。前阵子社区里讨论过 Laravel 的一些编号型 CVE 公告比如 CVE-2024-29291 的相关修复。这种漏洞公告出来后最有效的响应不是去网上找所谓“漏洞复现脚本”而是老老实实对照项目依赖版本及时执行依赖升级。比如 Laravel 项目根目录的composer.json里锁定了框架版本看到安全公告后第一时间执行composer update --dry-run查看可升级的包确认后更新并跑一遍回归测试。我自己的习惯是每周用composer audit扫一次依赖安全问题有问题就升级。同时无论 ThinkPHP 还是 Laravel生产环境必须做到这几条.env文件禁止被 Web 访问APP_DEBUG关闭数据库连接使用最小权限账号控制器里对用户输入做参数校验所有 SQL 用参数绑定所有表单加 CSRF Token。反序列化攻击也要防。给用户的输入永远不要直接unserialize而是走 JSON 格式配合白名单校验。日志、缓存、文件上传目录尽量不暴露在 Web 根目录可访问的区域。安全没有银弹像 CVE-2024-29291 这类公告提醒我们的是依赖版本管理、代码复核和定期审计才是真正的防线。我这里说一个自己踩过的安全坑刚开始上线时APP_DEBUGtrue忘了关结果线上一个异常页面把数据库密码打印出来了。当时运维截图给我我冷汗都下来了。后来我在部署脚本里加了一步强制检查如果.env中APP_DEBUG不为 falseCI 直接失败绝不允许发布。这个坑太典型了只要 debug 开在线上数据库密码、密钥、目录结构分分钟暴露即使没有任何主动攻击者日志被搜索引擎收录也是风险。6.2 线上问题排查的实操方法火车票系统最容易出现的问题无非三类查询慢、余票不一致、支付回调丢单。我按这三类给排查思路做一个速查表问题现象排查思路常用处理余票查询接口慢看 MySQL 慢查询日志是否走索引调整索引增加 Redis 缓存Redis 库存为负查看扣减逻辑是否忘记回滚补事务补偿加锁回调丢单看支付平台日志和本地订单状态加定时对账任务人工补单订单状态卡在“支付成功”查看队列是否消费失败观察队列日志重试失败任务后台列表打开很慢是否存在全表扫描分页大小、索引检查、字段索引优化排查时第一步永远是看日志。Laravel 日志默认写在storage/logs/laravel.logThinkPHP 在runtime/log/下按天生成。如果日志太多可以用tail -f实时跟踪筛选。开启 SQL 日志也很有用Laravel 里写DB::listen监听或直接用log驱动记录每条 SQL 的耗时ThinkPHP 可以打开配置文件的 SQL 日志开关。我自己的经验是支付回调这种关键路径一定要加“流水日志”每次回调进来、验签结果、订单状态、处理结果都打一条后面排查丢单直接按订单号 grep十分钟就能定位。比在代码里临时瞎输出强太多。还有对账脚本每天凌晨跑一次对比支付平台账单和本地支付表金额不一致自动告警。第一次上线后的第二天对账脚本就发现有两笔订单因为网络原因没有收到回调全靠脚本捞回来。从那以后我再也不敢把“能看日志”当成唯一防线了。同时库存对账脚本也不能省Redis 里的余票和数据库里的 sold_count 每天对一次发现问题就重新快照宁可多跑几遍也不能让错账过夜。这么一轮折腾下来我最大的体会是设计售票系统的时候先别急着写代码把库存模型和订单状态机画明白后面就是往骨架上填肉。如果你手里正好有 ThinkPHP 和 Laravel 共存的旧项目也不用急着迁移把边界画清楚、接口收敛好、日志补完整这套模式能稳稳跑好几年。最后分享一个小技巧上线前把“支付回调重复请求”“用户重复点击买票”“退票时网络超时”这三个场景单独做成测试用例反复跑几轮你能躲掉线上八成以上的事故。