简介:基于SpringCloud构建的饿了么O2O外卖系统后端数据库脚本包,围绕客户端、商家端、配送端、订单端及总后台的业务场景,提供核心数据模型定义,适合正在学习微服务架构的开发者、电商后端工程师或需要参考O2O系统表结构设计的项目经理使用。整个压缩包仅65KB,共3个SQL文件,分别对应配送订单、商城商品、客户订单等关键模块,脚本可直接导入MySQL,用于快速搭建本地演示环境,也能帮助理解各微服务之间的数据依赖关系。已有486人学习浏览,这套精简脚本以最小成本覆盖了外卖系统中的订单、商品、配送等核心实体,包含字段类型、主外键关系及基础索引设计,可作为SpringCloud整合数据库时的重要参考,也为二次开发或系统重构提供了清晰的表结构蓝图。通过梳理这些SQL,能够掌握O2O平台从用户下单到商家接单、骑手配送的完整数据流转链路,快速上手基于微服务的外卖项目数据库部分。
1. 这个 SpringCloud 外卖后端:5 个端在解决什么问题
如果你搜过“饿了么外卖系统 后端代码”,多半不是想研究微服务理论,而是希望客户端、商家端、配送端、订单端、总后台这些模块能在一个项目里真正跑起来。我做过外卖领域的后端开发,这类带全端目录的 SpringCloud 项目,本质上是一套面向 O2O 场景的微服务骨架:每个端拆成独立服务,通过注册中心互相发现,用网关统一收口,最后落到一套能扛并发下单的数据库设计上。它解决的核心痛点很具体——单体外卖项目后期基本都死在“订单状态乱、跨端接口互相纠缠”上。适合正在选毕业设计方向、或者中小团队想从单体升级微服务的人读。
2. 微服务拆分:把 5 个端映射成 SpringCloud 服务模块
2.1 客户端、商家端、配送端、订单端、总后台各对应哪个服务
拿到这种标题的项目,第一件事不是翻代码,而是先对“端”和“微服务”的映射关系。一个端不一定是单个服务,但最小可运行的拆法是这样,我一般会先搭 7 个模块:gateway、nacos、user-service(客户端)、merchant-service(商家端)、rider-service(配送端)、order-service(订单端)、admin-service(总后台)。表格里是每个端的职责边界,也是你验收项目时看 package 划分是否合理的依据。
| 端 | 服务模块 | 职责边界 | 主要数据表前缀 |
|---|---|---|---|
| 客户端 | user-service | 用户注册登录、收货地址、购物车展示、历史订单查询 | user_、cart_ |
| 商家端 | merchant-service | 商家入驻、菜品管理、营业状态、接单/拒单 | merchant_、menu_ |
| 配送端 | rider-service | 骑手信息、配送单管理、位置上报、配送完成回执 | rider_、delivery_ |
| 订单端 | order-service | 下单主流程、订单状态机、支付回调、超时关单 | order_、refund_ |
| 总后台 | admin-service | 商家审核、运营看板、全局配置、权限管理 | admin_、audit_ |
新手最容易搞错的是购物车归属。客户端 service 里只留购物车的展示接口,真正算钱、锁购物车、生成订单必须在订单端完成,否则客户端一挂,整个下单链路跟着不可用。客户端和服务端的边界一划成“客户端只做页面聚合,服务端只做业务落库”,后面的 Feign 调用和数据库权限都会清爽很多。另外数据库账号建议 5 个端各自建独立账号,避免某个服务连接异常把另一个服务拖下水。
2.2 注册中心:Nacos 的 namespace 和 group 怎么统一
Nacos 是这类项目最常用的注册中心。先启动 Nacos,单机模式默认监听 8848,然后每个服务的 bootstrap.yml 里做两件事:注册自己、订阅配置。以下是我常用的 order-service 配置:
# order-service/src/main/resources/bootstrap.yml spring: application: name: order-service cloud: nacos: discovery: server-addr: 192.168.1.10:8848 namespace: o2o-dev group: O2O_GROUP config: server-addr: 192.168.1.10:8848 namespace: o2o-dev group: O2O_GROUP file-extension: yaml shared-configs: -># gateway/src/main/resources/application.yml spring: cloud: gateway: routes: - id: user-route uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=1 - id: order-route uri: lb://order-service predicates: - Path=/api/order/** filters: - StripPrefix=1lb://必须写,它告诉网关去 Nacos 按服务名做负载均衡,而不是直连 IP。StripPrefix=1 表示把/api这一层去掉再转发,比如/api/order/create到 order-service 后变成/order/create。因此每个服务里要么配置spring.servlet.context-path=/order,要么接口类统一加@RequestMapping("/order")与它配套。这两边不统一,是这类项目里最常见的“链路通但 404”来源。
服务之间调用我用 OpenFeign。调用接口直接写在消费方,order-service 里调商家端查菜品是这样:
@FeignClient(name = "merchant-service", path = "/merchant") public interface MerchantClient { @GetMapping("/menu/{merchantId}") Result<List<MenuDTO>> getMenu(@PathVariable("merchantId") Long merchantId); }这里的 name 必须匹配目标服务的 application name,path 匹配对方 context-path。Feign 默认连接超时 1 秒、读超时 10 秒,商家端如果菜单查询走了慢 SQL,10 秒很容易被打满。我一般把读超时放到 5 秒,同时要求下游对菜单做 Redis 缓存,而不是把超时无限调大——超时调大只是把故障延迟暴露,不会消除故障。
3. 订单端核心链路:状态机与下单流程怎么落地
3.1 订单状态机:从待支付到完成,状态靠什么守门
订单端是整个系统的指挥中心。客户端下单、商家接单、骑手配送、后台看数据,全围绕订单状态展开。不用状态机的话,最常见的结果是:支付回调来了把订单改成已支付,超时关单任务又把它改成已取消,两边互相覆盖。
状态机我用枚举加一张“允许流转表”实现,不引入 Spring State Machine。理由很实际:外卖订单状态数量有限,引入重状态机反而让新人读代码成本变高。核心代码:
public enum OrderStatus { PENDING_PAYMENT(0, "待支付"), PAID(10, "已支付"), CONFIRMED(20, "商家已接单"), DELIVERING(30, "配送中"), COMPLETED(40, "已完成"), CANCELED(50, "已取消"), REFUNDING(60, "退款中"); private final int code; private final String desc; // key: 当前状态, value: 允许流转到的状态 private static final Map<Integer, Set<Integer>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(0, Set.of(10, 50)); TRANSITIONS.put(10, Set.of(20, 50, 60)); TRANSITIONS.put(20, Set.of(30, 50)); TRANSITIONS.put(30, Set.of(40)); TRANSITIONS.put(60, Set.of(50)); } public static boolean canTransition(int from, int to) { return TRANSITIONS.getOrDefault(from, Set.of()).contains(to); } }两个容易忽略的细节:待支付可以直接到已取消,但已支付不能直接到已取消,必须先进退款中;商家已接单还能取消,对应外卖业务里的无货拒单策略,但要同步通知配送端撤销配送单。所有状态流转在 Service 层先调canTransition再执行 UPDATE,非法流转直接抛业务异常。
3.2 下单链路:订单端怎么和其他端协作
下单是典型的跨服务流程,伪代码如下:
@Transactional public OrderVO createOrder(CreateOrderRequest req) { // 1. 锁定购物车,属于订单端本地事务 cartService.lockCart(req.getUserId(), req.getSkuList()); // 2. 校验商家营业状态,远程调用 merchantClient.checkOpen(req.getMerchantId()); // 3. 创建订单主记录,写待支付 Order order = buildOrder(req); orderDao.insert(order); // 4. 远程冻结库存 merchantClient.freezeStock(req.getMerchantId(), req.getSkuList()); // 5. 发送延迟关单消息 orderMq.sendDelayClose(order.getOrderNo()); return OrderVO.from(order); }这个写法看着顺,但有一个必须点破的坑:第 4 步远程冻结库存发生在本地事务里,如果第 5 步发 MQ 失败,本地事务回滚,库存却已经冻结了。我见过的项目第一版几乎都会踩这个。常见的可靠做法是把库存实时扣减改成“冻结库存”——步骤 4 只把可用库存变冻结库存,支付成功后再真实扣减;支付超时则解冻。这样即使订单服务挂了,库存也只是冻结状态,不会出现“订单没了、库存也没了”的对不上账。另外,步骤 2 和步骤 4 可以合并成一个远程接口,让商家端一次返回营业状态并试算库存,少一次网络往返,客户端下单响应会快不少。
提示:下单链路里所有远程调用都要打印 traceId 和耗时。我一般在网关生成 traceId 放进 header,下游服务把它带到日志里,排查“不知道卡在哪一步”的问题时,按 traceId 拉全链路日志就够了。
参数说明:远程调用一律设置超时。Feign 的读超时和服务端事务时长要匹配——order-service 本地事务如果跑 5 秒,Feign 读超时就不要设 3 秒,否则前端先收到超时,订单却落库了,客户端重试会导致重复单。
3.3 超时未支付:定时任务扫库为什么会被延迟消息替代
外卖订单 15 分钟未支付自动取消是刚需。老项目习惯用定时任务每 30 秒扫一次订单表,执行select ... where status=0 and create_time < now() - 15min,再批量改状态。这条路在数据量几十万以后非常吃力——全表扫描拖慢客户端发单,扫描间隙带来的误差又会导致“页面显示已支付但订单被取消了”。
我在项目里用 RocketMQ 延迟消息:下单时发一条延迟 15 分钟的消息,消息体只放 orderNo,消费端收到后先查状态、再做条件更新。两种方案对比:
| 方案 | 精确度 | 数据库压力 | 依赖组件 | 适合阶段 |
|---|---|---|---|---|
| 定时任务扫表 | 分钟级,有扫描间隙 | 高,定时全表扫描 | 无 | 日订单量 < 1 万 |
| RocketMQ 延迟消息 | 秒级 | 极低,只更新命中订单 | RocketMQ | 日订单量 > 1 万 |
教学或毕设项目没有 MQ 环境时,可以用 Redis 过期 key 回调做降级,但 Redis 删除回调不是强保证,生产环境不建议依赖它。
4. 数据库设计:撑住 5 个端的外卖表结构
4.1 核心表清单:订单、商家、配送、后台各需要哪些表
不看代码先看表,判断一个外卖后端靠不靠谱,我习惯直接翻数据库脚本。完整方案至少要有 10 张核心表,正好覆盖 5 个端模块:
| 模块 | 表名 | 说明 |
|---|---|---|
| 客户端 | user_info | 用户账号、手机号、状态 |
| 客户端 | cart_info | 购物车,含 sku 明细细粒度字段 |
| 商家端 | merchant_info | 商家基本信息、营业状态 |
| 商家端 | menu_item | 菜品、价格、库存、起售状态 |
| 订单端 | order_info | 订单主表,每单一条 |
| 订单端 | order_item | 订单明细,一单多商品 |
| 配送端 | rider_info | 骑手、接单状态 |
| 配送端 | delivery_record | 配送单、配送状态流转 |
| 总后台 | admin_user | 后台账号、角色 |
| 总后台 | audit_log | 商家审核、操作日志 |
特别容易漏的是 refund_order 退款单。外卖业务里退款和订单一对多,一个订单可能分多次退款,很多教程项目把退款金额直接写在 order_info 一个字段里,第二次退款时直接覆盖。我一般把退款单做成独立表,订单表只留一个“可退金额”字段做余额约束。
order_info 建表是核心,贴一个我常用的版本:
CREATE TABLE `order_info` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` bigint NOT NULL COMMENT '下单用户', `merchant_id` bigint NOT NULL COMMENT '商家ID', `delivery_addr_id` bigint DEFAULT NULL COMMENT '配送地址ID', `total_amount` decimal(10,2) NOT NULL COMMENT '实付金额', `refundable_amount` decimal(10,2) DEFAULT NULL COMMENT '可退金额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态,见状态机', `pay_type` tinyint DEFAULT NULL COMMENT '支付方式', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `status`), KEY `idx_merchant_status` (`merchant_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';字段设计说明:order_no 是给用户看的订单号,和自增主键区分开——主键分表后不能作为全局业务标识,订单号用时间戳加随机数生成并加唯一索引。idx_user_status 支撑客户端“我的订单”按状态筛选,idx_merchant_status 支撑商家端“待接单列表”,没有这两个联合索引,百万级数据下这俩列表查询必慢。refundable_amount 每次退款时做>= 退款金额校验,防止超退。
4.2 订单表为什么建议按月分表
分表理由不是“订单多”,而是订单表的访问模式太集中:客户端查最近订单、商家端查当日订单、后台查对账单,天然都带时间范围。按月分表把表拆成 order_info_202501、order_info_202502,分片键选 create_time,用 ShardingSphere 做路由是常见做法:
# sharding-order.yaml rules: sharding: tables: order_info: actualDataNodes: ds0.order_info_2025${01..12} tableStrategy: standard: shardingColumn: create_time shardingAlgorithmName: order_month shardingAlgorithms: order_month: type: INTERVAL props: datetime-pattern: yyyy-MM-dd HH:mm:ss datetime-lower: 2025-01-01 00:00:00 sharding-suffix-pattern: yyyyMM datetime-interval-amount: 1 datetime-interval-unit: MONTHS按时间分表有个隐藏好处:历史月份的表可以单独归档、单独备份,后台对账只查当月表,查询路径短很多。注意一个常见错误思路——按商家 ID 分表。用户一次可以下多商家的单,但订单归属在用户侧,按商家分表会导致“我的订单”跨表聚合,查询要并发扫所有分表再合并,性能反而不如一张大表。
4.3 MySQL 连接池与常用命令:定位慢 SQL 和死锁
连接池我用 HikariCP,默认参数上有两点必须改:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 30000参数说明:connection-timeout 单位是毫秒,默认 30 秒在微服务链路里太长了——连接池满了以后,客户端请求会排队 30 秒才超时,下单接口直接卡死。改成 3 秒是宁可快速失败让客户端重试,也不让线程挂着占资源。maximum-pool-size 不是越大越好,20 个连接对订单库足够,调到 200 反而会把数据库 CPU 打满。
常用排查命令我记三条:show processlist看当前慢查询;explain select * from order_info where user_id=? and status=?看有没有走索引,type 是 ref 才算正常;数据库死锁直接看show engine innodb status里的 LATEST DETECTED DEADLOCK 段。数据库增删改查的优化上有个反直觉的点:insert 在并发下翻车往往不是 insert 本身慢,而是前面多查了一次 select——比如“先查订单是否存在再插入”,两个线程同时查到不存在,同时插入,唯一索引兜不住业务规则,就撞死锁或出重复数据。正确做法是纯 insert 然后捕获 DuplicateKeyException。
5. 避坑:SpringCloud 外卖后端最常见的 4 个翻车现场
5.1 服务注册不上,客户端一直报 UnknownHostException
现象:order-service 启动后没异常,但 Feign 调 merchant-service 一直报UnknownHostException: merchant-service或no provider available;打开 Nacos 控制台,服务列表能看到 merchant-service,实例数却是 0。
原因:九成是 namespace 不一致。Nacos 的 namespace 是硬隔离,order-service 配了namespace: o2o-dev,merchant-service 没配,用的是默认 public,两边互相发现不了。另一成是机器多网卡,服务把 IP 注册成了 docker 虚拟网卡或本机虚拟机网卡的地址,注册中心拿到的是 172.x 或 169.254 开头,其他服务根本访问不到——这个属于典型的网络“玄学”,不把 IP 打进日志根本发现不了。
解决:所有服务统一 namespace 和 group,在 Nacos 服务列表点进详情看 IP 是不是内网 IP;多网卡机器在配置里显式写死:
spring: cloud: nacos: discovery: ip: 192.168.1.10 # 强制注册内网IP namespace: o2o-dev group: O2O_GROUP5.2 下单接口一到饭点就卡死
现象:客户端点提交订单平时 1 秒返回,高峰期要 5 秒以上,数据库 CPU 冲到 90%,服务日志里全是连接池拿不到连接的超时,整个链路像个黑匣子。
原因:微服务拆完后调用还是同步串行。客户端下单,订单端先查商家状态、再冻结库存、再调配送端预估时间,三个远程调用排着队;商家端一条慢 SQL 拖 2 秒,下游全跟着堵。客户端超时后自动重试,雪上加霜。
解决:非关键调用异步化。配送端预估送达时间这类延迟几秒展示也没关系的信息,改成下单成功后发 MQ 异步更新;关键链路只保留商家校验和冻结库存。另一个兜底是网关对/api/order/**配限流过滤器,单机 QPS 超阈值直接返回“系统繁忙”,保护数据库不被刷垮。
5.3 订单并发更新:重复关单和数据库死锁
现象:日志出现Deadlock found when trying to get lock; try restarting transaction,业务上表现为用户支付成功但订单状态还是已取消,或者已取消的订单扣了款。
原因:支付回调线程和超时关单消费者同时执行update order_info set status=? where id=?。支付想把 0→10,关单想把 0→50,两个事务都先 select 又都拿到旧值,更新时互相等锁,InnoDB 检测到死锁回滚其中一个。更隐蔽的是订单表和库存表更新顺序不一致:服务 A 先更新订单再更新库存,服务 B 先更新库存再更新订单,循环等待。
解决:两个层面。第一,状态更新改成 CAS 条件更新:
UPDATE order_info SET status = 50, update_time = NOW() WHERE order_no = #{orderNo} AND status = 0受影响行数为 0,说明订单已被支付回调改成已支付,直接放弃关单,不需要回滚什么。第二,涉及多张表的更新,所有服务按同一顺序加锁——先订单主表、后库存表。这个顺序要写进团队规范,靠数据库死锁重试机制是掩盖问题。
5.4 分布式事务翻车:Seata 不是万能的
现象:引入 Seata AT 模式后,商家端库存扣减成功,order-service 本地事务后续步骤异常回滚,商家端库存却没回滚,月底对账对不上。
原因:AT 模式要求所有参与事务的分支数据库都建undo_log表,并且数据源必须被 Seata 接管。最常见的情况有两个:商家端用了多数据源,Seata 只接住了主数据源,另一路连接绕过了全局事务;或者 undo_log 表建错了库,分支事务回滚时找不到 undo 记录。
解决:我的取舍是尽量不让 Seata 背业务。下单核心动作改成“冻结库存”:商家端接口只把可用库存变成冻结值,不真实扣减;订单支付成功后再把冻结变扣减,支付失败或超时则解冻。这个链路允许最终一致,不需要全局事务,即使订单服务挂了,库存也停在冻结状态,不会出现“订单没了、库存也没了”。什么时候才需要 Seata?跨服务强一致且不能接受补偿的场景——外卖项目里几乎没有,能不用就不用。
6. 进阶:从单体外卖后端拆到 SpringCloud 的最小改造路径
如果你手上已经有一套单体外卖后端,订单、商家、配送、后台都在一个工程里,拆微服务最忌讳“一步到位”。我吃过这个亏:一口气把五个端全拆了,上线当天网关、注册中心、数据库三处连环出问题,改了一晚上配置。
最小改造路径分三步。第一刀只拆订单域:把订单相关 Controller、Service、Mapper 抽成 order-service,其他功能留在单体里;单体对 order-service 暴露 Feign 接口,数据库还是原来那张订单表,不动表结构。第二刀拆商家域:菜谱和库存跟订单耦合最重,先把库存从“直接扣减”改成“冻结/扣减”两套逻辑,再拆服务,同时引入消息队列做库存变更通知。第三刀才拆配送端和总后台,这两个域边界清晰,独立性强,风险最低。
| 改造顺序 | 做什么 | 主要风险 | 验证手段 |
|---|---|---|---|
| 第一刀 | 拆订单域为独立服务 | 路径转发 404、事务边界变化 | 网关只切 10% 下单流量,观察 3 天 |
| 第二刀 | 拆商家域,库存改冻结模型 | 库存账不一致 | 下测试单核对库存流水 |
| 第三刀 | 拆配送域和后台域 | 配送调度通知丢失 | 配送全链路按 traceId 核对状态流转 |
验证方法只有一个标准:灰度。同一套 Nacos 命名空间下,网关把/api/order/**的一部分流量切到新服务,其余仍指向单体,两个入口共用同一个库和同一套状态机,出现不一致立刻回滚网关配置。我第一套系统翻车之后给自己定了个规矩:每次只拆一个服务,跑满一周再拆下一个。这样每个服务的边界问题都能在最小范围暴露,前端反馈的声音也能及时传到后端。希望帮到你。
本文还有配套的精品资源,点击获取