简介:这份资源是万岳外卖系统后台服务端的完整设计源码,面向外卖平台创业者、PHP后端开发者及需要搭建同城配送系统的技术团队,可解决从下单、配送到连锁餐饮管理的全链路业务需求。压缩包共2001个文件,约107.21MB,以915个PHP文件为核心,辅以322个JavaScript、258个HTML、87个CSS构建前后端交互,另有JSON配置、SQL建表脚本、Shell部署脚本及Dockerfile等运维文件,目录结构清晰,便于按模块检索。系统覆盖美食下单、扫码点餐、外卖调度中心、同城配送跑腿与智能调度等模块,并引入Swoole扩展支撑高并发订单处理。目前已有129人学习下载,适合开发者研读模块化架构、借鉴调度算法与异步处理思路,也可作为二次开发与毕业设计的参考底本。
1. 万岳外卖后台服务端:PHP 主控加多语言子服务的真实拆解
接手一个外卖平台的后台服务端,最怕的不是代码量大,而是技术栈散。万岳外卖系统后台服务端设计源码这套东西,核心特征就是 PHP 做主干,同时集成多种语言写的子服务——可能是 Python 跑调度算法,可能是 Go 扛高并发推送,也可能是 Java 处理对账。你拿到源码后第一反应往往是:入口在哪、哪些是 PHP 原生逻辑、哪些是跨语言调用、数据怎么串起来。这套源码解决的就是「一套后台管住多语言服务」的问题,适合已经有 PHP 基础、需要接手或二次开发外卖类后台的工程师。php源码这个圈子里,外卖系统属于业务密度高、调用链长的那一类,光看目录结构不够,得把请求从入口追到落库,才算真正看懂。
2. 先理清 PHP 主控与多语言子服务的边界
2.1 为什么外卖后台不适合纯 PHP 一把梭
外卖后台的业务模型天然分裂成两块。一块是强事务、强一致性的订单与资金流,另一块是高并发、低延迟的调度与推送。PHP 在 Web 请求生命周期里处理订单创建、状态流转、对账查询非常顺手,框架成熟、开发快、部署简单。但一旦涉及骑手位置批量计算、订单智能派单、消息扇出推送,纯 PHP 的常驻内存能力和协程生态就吃力了。
万岳这套源码的设计思路,我判断是典型的「PHP 管业务、子服务管算力」。PHP 层负责接收请求、鉴权、参数校验、写主库、发消息;子服务层负责消费消息、做计算、回写结果。这样拆的好处是:PHP 侧可以继续用你熟悉的 MVC 结构快速迭代业务,子服务侧可以用更适合的语言做专项优化。坏处也明显——跨语言调用的序列化、超时、重试、幂等,每一个都是坑。
常见做法是 PHP 通过 HTTP 或消息队列调用子服务。HTTP 调用简单直接,适合低频、可容忍毫秒级延迟的场景,比如报表生成、对账触发。消息队列适合高频、需要削峰的场景,比如订单创建后异步派单、状态变更后推送。源码里如果同时出现这两种调用方式,不要觉得乱,这是按业务特征分开的。
2.2 从入口文件追到子服务调用的完整链路
拿到源码先别急着改业务。第一步是找到 Web 入口,通常是public/index.php或类似位置。从这里开始,追路由定义、追控制器、追服务层,直到发现第一个跨语言调用点。
// public/index.php 典型入口结构 <?php // 定义应用根目录 define('APP_PATH', __DIR__ . '/../application/'); // 加载框架引导文件,不同框架文件名不同 require __DIR__ . '/../thinkphp/start.php';这段代码本身没难度,关键是它告诉你框架类型。ThinkPHP 系看application/下的模块划分,Laravel 系看routes/和app/Http/Controllers/。找到订单控制器后,重点看它调用了哪些 Service 类。
// application/api/controller/Order.php 片段 public function create() { // 参数校验与鉴权 $params = $this->request->post(); $this->validate($params, 'Order.create'); // 调用订单服务,内部可能触发子服务 $result = OrderService::getInstance()->createOrder($params); return json($result); }追到OrderService::createOrder内部,你会看到它先写订单主表,然后往消息队列投递一条派单消息。这条消息的消费者,很可能就是 Python 或 Go 写的子服务。判断依据是消息体格式和队列名称——如果队列名带dispatch、push、settle这类词,基本可以确定是跨语言边界。
提示:不要一上来就改子服务代码。先把 PHP 侧调用子服务的所有出口列出来,包括 HTTP 客户端封装类、队列投递方法、RPC 客户端,形成一张调用地图。
2.3 多语言子服务的接入方式与配置读取
子服务通常不直接暴露公网,而是通过内网地址或队列被 PHP 调用。源码里一般会有配置文件集中管理这些地址。以 ThinkPHP 为例,可能在config/下有一个service.php或queue.php。
// config/service.php 子服务地址配置示例 return [ // 派单计算服务,Go 编写,HTTP 接口 'dispatch_service' => [ 'host' => env('DISPATCH_HOST', '127.0.0.1'), 'port' => env('DISPATCH_PORT', 9100), 'timeout' => 3, // 秒,派单不能等太久 ], // 消息推送服务,Python 编写,走队列 'push_queue' => [ 'driver' => 'redis', 'queue' => 'push_order_status', 'connection' => 'default', ], ];参数说明:timeout是跨语言调用最容易翻车的地方。PHP 默认的 HTTP 超时可能很长,但派单场景下超过 3 秒没结果,用户已经取消订单了。所以子服务调用必须设短超时,并且 PHP 侧要有降级逻辑——超时后走人工派单或轮询补偿。
env()读取环境变量是常见做法,部署时不同环境改.env即可,不用动代码。如果你拿到的源码里地址是硬编码的,第一件事就是把它抽到配置文件。
2.4 数据一致性:PHP 写库与子服务回写的冲突处理
这是整套架构里最需要盯紧的地方。PHP 创建订单后投递消息,子服务消费后可能回写订单状态或骑手信息。如果子服务处理失败或重复消费,数据就乱了。
源码里通常会有几种应对手段。一是消息带唯一业务 ID,子服务侧做幂等判断。二是 PHP 侧写库和投递消息放在同一个本地事务里,但消息投递本身不是事务性的,所以常见做法是「本地消息表」——先写消息表,再异步投递,投递成功才标记。三是子服务回写时带版本号或状态机校验,防止旧状态覆盖新状态。
// 本地消息表投递逻辑示意 Db::startTrans(); try { // 写订单主表 $orderId = Db::name('order')->insertGetId($orderData); // 写本地消息表,与订单同一事务 Db::name('local_message')->insert([ 'biz_id' => $orderId, 'queue' => 'dispatch_order', 'payload' => json_encode(['order_id' => $orderId]), 'status' => 0, // 0 待投递 'create_time' => time(), ]); Db::commit(); } catch (\Exception $e) { Db::rollback(); throw $e; } // 事务提交后再触发异步投递,投递成功更新 status=1这段逻辑的关键在于:订单和消息记录在同一个数据库事务里,保证「订单创建成功则消息一定被记录」。后续即使投递失败,也有补偿任务扫描status=0的记录重投。子服务侧收到消息后,先查local_message或业务表判断是否已处理,避免重复派单。
注意:不同数据库对事务隔离级别的默认值不同,MySQL 的 RR 级别下,本地消息表的写入和订单写入在同一事务中,不会出现订单可见但消息不可见的情况。但如果用了读写分离,要确保投递任务读的是主库。
3. 把源码跑起来:环境、依赖与最小验证
3.1 PHP 运行环境与扩展依赖清单
外卖后台对 PHP 扩展的依赖比普通 CMS 多。除了常规的pdo_mysql、redis、curl、json,还可能用到bcmath(金额计算)、swoole(如果 PHP 侧有常驻进程)、zip(导出报表)。源码根目录一般有composer.json,先看require段。
# 检查 PHP 版本与关键扩展 php -v php -m | grep -E 'pdo_mysql|redis|curl|bcmath|swoole|zip' # 安装 Composer 依赖,生产环境加 --no-dev composer install --no-dev --optimize-autoloader--optimize-autoloader会生成类映射文件,减少线上自动加载开销。如果源码里带了composer.lock,不要删,按锁文件安装才能保证依赖版本一致。我见过有人直接composer update把框架小版本升上去,结果路由行为变了,血泪经验。
如果提示no package 'libzip' found,说明系统缺少 libzip 开发库,PHP 的 zip 扩展编译不过。Ubuntu 下apt install libzip-dev,CentOS 下yum install libzip-devel,然后重新编译安装 zip 扩展。宝塔面板用户可以在软件商店里直接装扩展,但要注意 PHP 版本对应。
3.2 数据库初始化与多语言子服务的启动顺序
数据库脚本通常在database/或doc/下,可能是.sql文件。导入前先建库、设字符集。
CREATE DATABASE wanyue_takeout DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;# 导入表结构与基础数据 mysql -u root -p wanyue_takeout < database/install.sql mysql -u root -p wanyue_takeout < database/base_data.sql启动顺序有讲究。先起 MySQL 和 Redis,再起 PHP 的 Web 服务,最后起子服务。因为子服务启动时可能要去数据库加载配置或订阅队列,如果数据库没就绪,子服务会报错退出。PHP 侧如果配置了启动时检查子服务健康状态,顺序反了也会启动失败。
# 子服务启动示例,具体命令看源码里的 README 或脚本 # Go 子服务 ./dispatch-service --config ./configs/dispatch.yaml & # Python 子服务 python3 push_worker.py --queue push_order_status &启动后不要急着测业务。先用curl或 Postman 调一个健康检查接口,确认 PHP 能通。再手动往队列里塞一条测试消息,看子服务是否消费并回写。这一步能提前暴露序列化格式不匹配、队列名称写错、权限不足等问题。
3.3 用一条订单请求验证 PHP 与子服务是否打通
最小验证路径:调创建订单接口,观察 PHP 日志、队列长度、子服务日志、数据库订单状态变化。
# 调创建订单接口,参数按源码实际接口文档调整 curl -X POST http://127.0.0.1:8000/api/order/create \ -H "Content-Type: application/json" \ -H "Authorization: Bearer <token>" \ -d '{"user_id":1,"shop_id":1,"goods":[{"id":1,"num":2}],"address_id":1}'请求返回成功后,立刻查三处。第一,order表有没有新记录,状态是什么。第二,Redis 里对应队列的llen有没有增加,如果增加后很快归零,说明子服务在消费。第三,子服务日志有没有打印收到消息和回写结果。
如果订单创建成功但队列没消息,检查 PHP 侧投递逻辑是不是被条件分支跳过了,比如环境判断、开关配置。如果队列有消息但子服务没消费,检查子服务连的 Redis 库号和队列名是否一致。如果子服务消费了但订单状态没变,检查回写 SQL 的条件和字段映射。
提示:验证阶段把 PHP 的日志级别调到 debug,子服务的日志也开到 debug,两边对照时间戳看,比猜快得多。
4. 跨语言调用避坑:序列化、超时与幂等
4.1 序列化格式不统一导致子服务解析失败
PHP 的json_encode默认把中文转成 Unicode 转义,把浮点数按serialize_precision输出。Python 的json.loads能处理,但 Go 的encoding/json对数字类型敏感——如果 PHP 传过来的是字符串"1",Go 结构体里定义的是int,就会报cannot unmarshal string into Go struct field。
// PHP 侧投递消息时显式处理 $payload = json_encode([ 'order_id' => (int)$orderId, // 确保是整型 'amount' => (float)$amount, // 确保是浮点 'remark' => $remark, // 字符串原样 ], JSON_UNESCAPED_UNICODE); // 中文不转义,方便排查JSON_UNESCAPED_UNICODE让日志里的中文可读,排查时不用再去解码。但要注意,如果子服务对消息体大小有限制,中文不转义会略微增大体积,一般可忽略。
Go 侧结构体定义要和 PHP 输出严格对齐:
type OrderMessage struct { OrderID int64 `json:"order_id"` Amount float64 `json:"amount"` Remark string `json:"remark"` }如果 PHP 侧order_id是字符串,Go 侧要么改成string再手动转,要么在 PHP 侧强转。我一般选后者,让边界清晰。
4.2 超时设置与重试策略的配合
PHP 调子服务 HTTP 接口,超时设多少要看业务。派单接口 2 到 3 秒,推送接口 1 秒,对账接口可以 10 秒。超时后 PHP 侧不能无限重试,否则子服务压力翻倍。
// Guzzle 客户端超时与重试配置 $client = new \GuzzleHttp\Client([ 'timeout' => 3.0, // 总超时 'connect_timeout' => 1.0, // 连接超时 ]); try { $response = $client->post($url, [ 'json' => $payload, 'headers' => ['X-Request-Id' => uniqid('', true)], ]); } catch (\GuzzleHttp\Exception\ConnectException $e) { // 连接失败,记录并走降级 Log::error('dispatch connect fail', ['order_id' => $orderId]); // 降级:标记为待人工派单 } catch (\GuzzleHttp\Exception\ServerException $e) { // 5xx,可重试一次,但要带幂等键 }X-Request-Id是幂等键,子服务侧用它去重。重试只对连接失败和 5xx 做,4xx 不重试,因为参数错了重试也没用。重试次数不要超过 1 次,且要加退避,比如 200ms 后再试。
4.3 幂等键的设计与子服务侧去重实现
幂等键最好用业务唯一标识,比如order_id + action。子服务收到消息后,先查 Redis 或数据库里有没有这个键的处理记录。
# Python 子服务幂等处理示意 import redis r = redis.Redis() def handle_order(msg): key = f"idempotent:{msg['order_id']}:dispatch" # setnx 返回 True 表示首次处理 if not r.setnx(key, 1): return # 已处理过,直接跳过 r.expire(key, 86400) # 24 小时后过期,避免键无限增长 # 实际业务处理 do_dispatch(msg)setnx加过期时间是常见做法。过期时间要大于业务可能的重试窗口,24 小时对大多数外卖场景够用。如果子服务处理失败需要重试,注意不要在失败时删掉幂等键,否则重试会重复执行。正确做法是:处理成功才保留键,处理失败记录错误日志并让消息重回队列,但重回的消息要带重试次数,超过阈值进死信队列。
注意:Redis 如果做了主从切换,
setnx在主从同步间隙可能失效。对资金类操作,幂等判断要落到数据库唯一索引上,不能只靠 Redis。
5. 后台服务端常见问题排查
5.1 订单创建成功但子服务没收到消息
现象:接口返回成功,数据库有订单,但队列长度不变,子服务日志无输出。
原因:PHP 侧投递逻辑在事务提交前执行,事务回滚导致消息已投递但订单不存在;或者投递被配置开关关闭;或者队列连接指向了错误的 Redis 库。
解决:检查投递代码是否在Db::commit()之后。检查配置文件里队列的select库号是否和子服务一致。在投递方法入口加日志,确认是否执行到。
5.2 子服务回写后订单状态被旧数据覆盖
现象:订单状态先变成「已派单」,几秒后又变回「待派单」。
原因:子服务回写没有带状态机校验,或者多个子服务并发回写同一订单,后写的覆盖了先写的。
解决:回写 SQL 加状态条件,比如UPDATE order SET status=2 WHERE id=? AND status=1。如果影响行数为 0,说明状态已被其他流程改变,放弃本次回写并记录日志。
5.3 PHP 调用子服务偶发超时但子服务日志显示处理成功
现象:PHP 侧报超时,但子服务日志显示已处理完成并回写。
原因:网络抖动或子服务处理时间接近超时阈值,PHP 先断开,子服务后完成。回写可能成功,但 PHP 侧认为失败并触发降级,导致重复处理。
解决:PHP 侧超时后不要立即降级,先查一次订单当前状态。如果子服务已回写,直接返回成功。同时子服务侧幂等键要覆盖这种「PHP 认为失败但实际成功」的情况。
5.4 多语言子服务日志时间戳不一致导致排查困难
现象:PHP 日志和子服务日志时间对不上,无法确定调用顺序。
原因:不同服务器时区不同,或者容器内 UTC 与宿主机 CST 混用。
解决:所有服务统一用 UTC 时间戳写日志,展示时再转本地时区。PHP 侧date_default_timezone_set('UTC'),Python 侧time.time(),Go 侧time.Now().UTC()。日志里同时打印毫秒级时间戳和请求 ID,靠请求 ID 串联比靠时间更可靠。
5.5 源码里的硬编码地址导致换环境就挂
现象:本地跑通,部署到测试环境后子服务调用全部失败。
原因:源码里子服务地址写成了127.0.0.1或某个固定内网 IP,没有走配置。
解决:全局搜索127.0.0.1、localhost、192.168、10.等网段,把子服务地址、数据库地址、Redis 地址全部抽到.env或配置文件。用env()读取,并给默认值。部署时只改环境变量,不动代码。
6. 二次开发前值得做的三件事:调用地图、压测基线、降级开关
接手这套源码做二次开发,最怕的是改了一个点,崩了一条链。我一般会在动业务代码之前先做三件事,花不了太多时间,但能省掉后面大量返工。
第一件,画调用地图。不是画给领导看的那种架构图,而是给自己用的「请求到落库」追踪表。从每个 API 入口开始,记录它经过哪些控制器、服务类、是否触发子服务、子服务是 HTTP 还是队列、回写哪些表。用表格整理,一行一个接口。
| 接口 | 控制器 | 是否调子服务 | 调用方式 | 回写表 | 幂等键 |
|---|---|---|---|---|---|
| 创建订单 | Order::create | 是 | 队列 | order, dispatch_log | order_id:dispatch |
| 取消订单 | Order::cancel | 是 | HTTP | order, refund | order_id:cancel |
| 查询订单 | Order::detail | 否 | 无 | 无 | 无 |
这张表能帮你快速判断改一个接口会影响哪些下游。比如你要改订单状态字段,一看表就知道取消订单和创建订单都会回写,得同步改。
第二件,建压测基线。不用搞复杂的全链路压测,先用ab或wrk对核心接口打一轮,记录 QPS、P99 延迟、子服务队列积压情况。
# 对创建订单接口压测,注意替换 token 和参数 wrk -t4 -c100 -d30s -s post.lua http://127.0.0.1:8000/api/order/createpost.lua里写好请求体和 header。压测时盯着 Redis 队列长度和子服务 CPU。如果队列持续增长,说明子服务消费能力不够,要么加消费者,要么优化子服务逻辑。这个基线数据在你后续改代码后可以对比,判断有没有引入性能退化。
第三件,给所有跨语言调用加降级开关。源码里可能没有,自己加。配置中心或.env里放一个dispatch_enabled=true,PHP 侧调用前判断。子服务不可用时,关掉开关,走本地降级逻辑——比如派单降级为按距离排序取第一个骑手,推送降级为站内信。
// 降级开关判断 if (!env('DISPATCH_ENABLED', true)) { // 走本地降级派单 return $this->localDispatch($orderId); }这个开关在子服务升级、故障排查、压测隔离时特别有用。我吃过亏,子服务在灰度新版本,PHP 侧没开关,结果新版本有 bug,所有订单派单卡住。后来加了开关,再遇到类似情况,一键切回本地逻辑,用户无感知。
最后说一个习惯:每次改完跨语言调用相关的代码,不要只测正常路径。手动把子服务停掉,看 PHP 侧是否按预期降级;手动往队列塞一条格式错误的消息,看子服务是否进死信而不是崩溃;手动把幂等键删掉,看重复消息是否被拦截。这些异常路径跑一遍,比读十遍代码都管用。希望帮到你。
本文还有配套的精品资源,点击获取