
简介基于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: - StripPrefix1 - id: order-route uri: lb://order-service predicates: - Path/api/order/** filters: - StripPrefix1lb://必须写它告诉网关去 Nacos 按服务名做负载均衡而不是直连 IP。StripPrefix1 表示把/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}) ResultListMenuDTO getMenu(PathVariable(merchantId) Long merchantId); }这里的 name 必须匹配目标服务的 application namepath 匹配对方 context-path。Feign 默认连接超时 1 秒、读超时 10 秒商家端如果菜单查询走了慢 SQL10 秒很容易被打满。我一般把读超时放到 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 MapInteger, SetInteger 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 status0 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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-devmerchant-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/**的一部分流量切到新服务其余仍指向单体两个入口共用同一个库和同一套状态机出现不一致立刻回滚网关配置。我第一套系统翻车之后给自己定了个规矩每次只拆一个服务跑满一周再拆下一个。这样每个服务的边界问题都能在最小范围暴露前端反馈的声音也能及时传到后端。希望帮到你。本文还有配套的精品资源点击获取