
简介基于SpringCloud的饿了么O2O外卖系统后端代码资源覆盖客户端、商家端、配送端、订单端及总后台等核心模块适合希望理解微服务架构在外卖场景落地同时学习O2O数据模型设计的开发者。压缩包共3个文件均为SQL脚本压缩包仅65KB对应配送订单、商城、客户订单等关键库表结构为后端服务提供直接可用的数据层基础。目前已有486人浏览学习在同类精简SQL代码包中具备一定关注度。通过梳理这些建表脚本与模块归属可清晰看到外卖系统多端协同下的订单流转路径、表间关联关系并体会SpringCloud服务拆分与数据库设计如何相互对应对正在规划O2O平台后端或准备面试相关项目的开发者是一份轻量但实用的参考样例。学习者可据此快速还原典型外卖数据库环境为后续接口联调或二次开发奠定基础。1. O2O外卖后端不只是一堆接口从单体到SpringCloud的拆法把饿了么这套O2O外卖系统的后端代码拆开看你会发现它不是一个「大项目」而是客户端、商家端、配送端、订单端、总后台五个子系统在同一个SpringCloud体系里协作。很多团队拿到这种系统第一反应是「不就是 CRUD 吗数据库增删改查写完就完事」真上线后订单状态错乱、商家菜品不同步、配送调度延迟全部集中爆发。原因是外卖业务天然高频、高并发、多角色单体应用扛不住数据库一张大表也扛不住。这篇笔记我把这套系统的服务拆分、数据库归属、核心链路代码和踩坑点完整过一遍。适合正在做电商/O2O后端、打算用SpringCloud搭微服务的人也适合刚接手这类老系统想搞清它怎么运转的人。2. 五端服务怎么拆客户端、商家端、配送端、订单端、总后台的边界与数据库归属2.1 按角色拆服务不按功能模块拆外卖系统的业务角色很清晰用户下单、商家出餐、骑手配送、平台运营。所以服务边界要跟角色走而不是跟功能走。常见做法是拆成四个业务服务加一个管理服务客户端服务user-client用户注册登录、地址管理、下单入口、订单查询。商家端服务merchant-service商家入驻、菜品管理、接单、出餐状态更新。配送端服务delivery-service骑手定位、接单、配送状态流转。订单端服务order-service订单创建、支付回调、订单状态机、超时取消。总后台admin-backend运营后台聚合各端数据做统计、审核、配置管理。注意客户端和商家端不要做「大而全」的接口比如用户下单这个动作客户端服务只负责接收请求、做参数校验真正的订单创建逻辑必须落到订单端。原因是订单端要同时被客户端、商家端、配送端调用如果下单逻辑放在客户端服务里商家端和配送端想改订单状态就得反向依赖客户端服务调用乱成蜘蛛网。2.2 数据库按服务拆分公共数据单独建库每个服务持有自己的数据库禁止跨库 join。订单库只存订单和订单明细商家库只存商家、菜品、分类配送库只存骑手和配送记录客户端库只存用户和地址。总后台不单独建业务库它通过接口聚合各端数据但平台级的配置数据城市区域、公告、审核规则要单独放一个 admin 配置库。这样拆的核心理由是数据库连接池和事务边界能跟着服务走。订单服务高峰期每秒上千次写如果它还去查用户库的会员等级、查商家库的菜品价格一次下单要跨三个库事务根本没法控制。外卖对订单一致性要求极高跨库调用只允许通过服务接口不允许直接操作别人的表。服务数据库核心表客户端user_dbuser、user_address、user_coupon商家端merchant_dbmerchant、category、dish、dish_sku订单端order_dborders、order_item、order_log配送端delivery_dbrider、delivery_order总后台admin_dbadmin_user、region、announcement、audit_log我用过的项目里订单表按 user_id 做分库分表商家表和菜品表按 merchant_id 水平拆分。如果你刚起步先按服务分库单表单库跑通后再上分库分表别一上来就引 ShardingSphere否则排查问题时会同时面对业务逻辑和分片规则两个黑匣子。2.3 网关和注册中心是串起五端的粘合剂五个服务之间要通信靠的是 SpringCloud Gateway 统一入口Nacos 做注册中心和配置中心。客户端请求只打网关网关按路径前缀路由到对应服务比如/user/**到客户端服务/merchant/**到商家端/order/**到订单端。服务之间的内部调用用 OpenFeign负载均衡交给 Nacos 自带的能力。总后台比较特殊它要跨端聚合数据所以它既是服务提供方也是各个业务服务的消费方。我一般会给总后台单独一套 Feign 接口避免把运营后台的聚合查询写到业务服务里防止一个统计接口把订单库拖垮。3. 把五端跑起来的SpringCloud工程下单链路与总后台聚合的代码实例3.1 父工程依赖管理先把版本对齐SpringCloud 和 SpringBoot 有严格的版本对应关系SpringCloud 2023.x 对应 SpringBoot 3.2.xSpringCloud Alibaba 2023.0.x 对应 Nacos 2.3.x。版本没对齐会出现注册不上、Feign 报类转换异常等玄学问题我统一用 spring-cloud-alibaba-dependencies 做依赖导入。dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version2023.0.1/version typepom/type scopeimport/scope /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-alibaba-dependencies/artifactId version2023.0.1.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement这段配置放在父 pom 的 dependencyManagement 里子模块不需要写版本号。逻辑上就是先把 SpringCloud 官方和 Alibaba 生态的版本统一锁死避免出现 Nacos 客户端版本和注册中心服务端不匹配的问题。注意 spring-cloud-alibaba 版本号是四位段和 SpringCloud 的版本号规则不一样不要混用。3.2 客户端下单Feign 调用订单端订单端再调商家端下单是最典型的三段式调用客户端服务接收请求Feign 调订单端创建订单订单端 Feign 调商家端锁定菜品和价格。锁价格这步很多人忽略实际必须做因为商家端改价后用户看到的已下单价格不能变。FeignClient(name order-service, contextId orderFeignClient) public interface OrderFeignClient { PostMapping(/order/create) OrderCreateResult createOrder(RequestBody OrderCreateRequest request); }Service public class OrderServiceImpl implements OrderService { Override Transactional(rollbackFor Exception.class) public OrderCreateResult createOrder(OrderCreateRequest request) { // 1. 远程锁定菜品价格和库存 ListDishLockResult lockResults dishFeignClient.lockDish(request.getMerchantId(), request.getItems()); // 2. 计算订单金额 BigDecimal totalAmount lockResults.stream() .map(DishLockResult::getLockPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); // 3. 写订单主表和明细表 Orders order buildOrder(request, totalAmount); orderMapper.insert(order); // 4. 记录状态日志 orderLogMapper.insert(buildOrderLog(order, OrderStatus.CREATED)); return OrderCreateResult.success(order.getOrderId()); } }这个事务里做了三件事远程锁菜、计算金额、写订单表全程在一个事务里。注意远程调用和本地事务放在一起有风险商家端锁菜成功但订单表写失败时事务回滚并不会自动通知商家端释放锁所以商家端锁菜接口里必须加超时自动释放逻辑。contextId参数是防止同一个服务里多个 FeignClient 同名时报 Bean 冲突这个参数在老版本里没有升级到 SpringCloud 2023 后遇到过几次最好每个 Feign 接口都加上。3.3 总后台聚合查询Feign 批量拉取再内存组装总后台要显示一个订单列表带上用户昵称、商家名称、骑手姓名跨了四个库。正确的做法是订单端先分页查出订单列表再根据 user_id 集合和 merchant_id 集合并行调用客户端和商家端批量查询最后在总后台服务里组装。禁止在总后台直接 Feign 循环单条查询N1 问题会把你数据库连接池打满。public AdminOrderPageVO queryOrderPage(OrderPageQuery query) { // 1. 订单端分页查询 PageOrders orderPage orderFeignClient.pageQuery(query); if (orderPage.getRecords().isEmpty()) { return new AdminOrderPageVO(0L, Collections.emptyList()); } // 2. 抽取用户ID和商家ID集合 SetLong userIds orderPage.getRecords().stream() .map(Orders::getUserId).collect(Collectors.toSet()); SetLong merchantIds orderPage.getRecords().stream() .map(Orders::getMerchantId).collect(Collectors.toSet()); // 3. 批量调用拿到聚合数据 MapLong, UserInfo userMap userFeignClient.batchGetUsers(userIds); MapLong, MerchantInfo merchantMap merchantFeignClient.batchGetMerchants(merchantIds); // 4. 内存组装返回 return buildPageVO(orderPage, userMap, merchantMap); }这是个很典型的聚合模式先把一次分页查询变成三次接口调用。批量查询接口的入参是一个 ID 集合注意控制集合大小我一般限制一次不超过 200 个 ID超过了就拆批调用。用 Hadoop 或大批量导出场景另说后台页面这个量级足够了。4. 订单一致性才是外卖系统的命门状态机、分布式事务与数据库设计4.1 订单状态机数据库里只存状态码外卖订单状态流转非常明确待支付、已支付、商家已接单、配送中、已完成、已取消、退款中、已退款。状态机必须在前端和后端双重定义后端才是唯一权威。数据库里订单表只存一个整数状态码不要直接存「已支付」「待支付」这种中文字符串否则后续状态判断和索引查询都会很痛苦。CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT 订单号, user_id BIGINT NOT NULL, merchant_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已接单 3配送中 4已完成 5已取消, total_amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_merchant_id (merchant_id), KEY idx_status_create_time (status, create_time) );状态流转的校验放在 service 层每次更新都带上前置状态条件。比如商家接单的 update 语句是update orders set status 2 where order_id ? and status 1影响行数为 0 就说明状态已变要抛异常或走补偿逻辑。这种乐观写法在高并发下比先查后更保险能防止两个请求同时把同一订单改成不同状态。4.2 分布式事务本地消息表比 Seata 更实用外卖系统跨服务调用频繁如果每个下单都要走 Seata 的全局事务性能损耗很大。我一般在两个场景做取舍强一致性场景用 Seata AT 模式比如支付回调改订单状态、扣减商家库存弱一致性场景用本地消息表比如订单完成后给用户发优惠券、给商家生成对账单。本地消息表的做法是在订单库里建一张 message 表业务操作和消息写入放同一个本地事务然后定时任务扫描消息表把消息发送到 RocketMQ消息发送成功更新状态。这样业务库和消息库的一致性靠本地事务保证投递的可靠性靠 MQ ack 保证。Override Transactional(rollbackFor Exception.class) public void cancelOrderExpired(Long orderId) { Orders order orderMapper.selectById(orderId); if (order null || order.getStatus() ! 0) { return; } // 1. 更新订单状态为已取消 orderMapper.updateStatus(orderId, 0, OrderStatus.CANCELLED.getCode()); // 2. 写一条可靠消息 TransactionMessage msg TransactionMessage.builder() .bizType(ORDER_CANCELLED) .bizId(orderId) .status(0) .build(); transactionMessageMapper.insert(msg); }这里的巧妙之处在于订单状态更新和消息写入在同一个事务里要么都成功要么都失败。后面定时任务把 status 0 的消息捞出来发到 MQ商家端消费后释放库存。这套方案比直接用 Seata 简单性能也好缺点是消息可能重复投递消费端必须做幂等。4.3 库存扣减要防超卖商家菜品库存的扣减是外卖系统高并发下的重灾区。用户下单时锁库存订单超时取消时释放库存如果直接update dish_sku set stock stock - 1 where sku_id ? and stock 0已经足够扛住大部分场景但遇到秒杀或者高峰期大量用户抢同一个商家时这一行 update 会拖慢整个商家库。常见做法是 Redis 预扣加数据库异步扣减。把库存预热到 Redis下单时在 Redis 里做扣减然后发送 MQ 异步落库。这里有个血泪经验Redis 扣减成功但 MQ 发送失败时Redis 库存已经被扣了需要定时任务对账补偿把 Redis 和数据库的库存差距修正。5. 数据库高频查询避坑深分页、死锁、主从延迟与连接池的 5 条踩坑记录5.1 深分页让总后台列表越翻越慢现象总后台按订单列表翻到 100 页以后接口响应从 200ms 涨到 3 秒。原因MySQL 的limit 10000, 20需要扫描前 10020 行再丢弃前 10000 行深分页的偏移量越大扫描越慢。解决改成游标分页用上一页最后一条订单的 create_time 和 order_id 做条件。-- 错误写法 SELECT * FROM orders WHERE merchant_id ? ORDER BY create_time DESC LIMIT 10000, 20; -- 正确写法 SELECT * FROM orders WHERE merchant_id ? AND (create_time, order_id) (2024-05-01 12:00:00, 123456) ORDER BY create_time DESC LIMIT 20;要注意游标分页要求排序字段唯一只用 create_time 会出现两条订单时间相同导致漏数据所以必须带上 order_id 做联合游标。总后台如果要做导出全部订单的功能建议改为异步任务分批查不要一口气查几十万条。5.2 数据库死锁商家端并发接单引发的更新顺序问题现象商家端两个操作同时进行一个是修改菜品库存一个是更新订单状态日志里频繁报 Deadlock found。原因两个事务分别持有不同订单/菜品的行锁然后互相等待对方释放锁。最常见的是更新订单表和更新菜品表的顺序在两个事务里不一致。一个事务先更新订单表再更新菜品表另一个事务先更新菜品表再更新订单表。解决在代码层强制统一加锁顺序所有涉及订单和菜品的操作都先更新订单表、再更新菜品表。MySQL 的行锁策略是两阶段锁锁在事务提交时才释放所以事务里尽量少做远程调用避免长时间持锁。5.3 客户端服务注册不上Nacos 网络配置背锅现象客户端服务启动后日志显示注册成功但网关请求/user/**时报 503查看 Nacos 服务列表里没有这个服务。原因Nacos 客户端注册时默认用本机 IP如果服务器上有多个网卡或配置了虚拟网卡客户端会拿到一个外部无法访问的 IP 地址服务注册进去但别的服务调不通。解决在客户端服务配置里强制指定注册 IP。spring: cloud: nacos: discovery: ip: 172.16.10.20 port: 8080还有一个不常见但真实存在的情况服务所在机器的 hosts 文件里配置了错误的映射关系导致 Nacos 客户端解析注册中心地址时连不上。排查时先看 Nacos 服务端日志里客户端上报的 IP 和端口再在服务所在机器上 telnet 那个 IP 的端口基本能定位。5.4 主从延迟导致订单状态前后不一致现象用户在客户端刚支付成功刷新订单列表却还是待支付几秒后才正常。客服后台查订单也偶尔看到旧状态。原因支付回调改的是订单主库订单列表查询走的是从库MySQL 主从同步有延迟高峰期延迟可能到几秒。数据库同步软件只是把 binlog 往从库搬延迟大小取决于主库负载和带宽。解决订单列表不强求实时一致的话从库读没问题但支付成功后要立刻展示订单状态必须强制走主库。做法是给查询接口加一个参数或者在订单表加一个paid_flag字段支付成功时先把这个字段在主库置 1列表查询只用这个维度过滤主从延迟只影响更新时间不影响业务展示。5.5 数据库连接池被打满应用假死现象接口响应全部超时日志里报Cannot get a connection, pool exhausted。Druid 监控面板里 activeCount 一直是最大连接数。原因某个慢 SQL 把连接占住不释放常见是总后台的分页大查询或订单端的批量接口在循环查库。另一个隐藏原因是事务里做了 long 时间的外部 HTTP 调用数据库连接被事务一直持有。解决调整 druid 连接池参数是关键第一步。spring: datasource: druid: initial-size: 5 min-idle: 5 max-active: 50 max-wait: 60000 validation-query: SELECT 1 test-while-idle: truemax-wait设为 60 秒是为了连接耗尽时快速失败而不是无限等待test-while-idle保证空闲连接不被 MySQL 八小时超时断掉。但连接池参数只是兜底真正要查的是慢 SQL 日志把执行超过 1 秒的 SQL 都抓出来优化。还有一个容易被忽略的点事务方法里不能捕获异常后吞掉否则事务拦截器感知不到异常连接永远不会释放。6. 留给上线前的检查本地跑通五端后的验证方法与一个压测技巧五端服务都跑起来后我一般先做三件事验证链路是否真的通。第一打开 Nacos 控制台确认五个服务全部注册用curl直接打网关看服务是否正常响应。第二用客户端下一个单在订单库里看订单记录在商家库里看菜品锁库存记录两边的数据要能对得上。第三把订单状态从待支付流转到配送中每步都查 order_log 表确认状态机没有跳状态。压测时最值得做的不是什么复杂压测方案而是拿 JMeter 模拟一个「用户下单 商家接单 骑手开始配送 骑手完成配送」的闭环脚本线程数从 100 逐步加到 500、1000每轮跑 5 分钟同时盯着 Nacos 和数据库连接池监控。我踩过一个大坑压测只在订单端打 API没带商家端和配送端结果上线后发现这三端互相调用时网络超时一片。微服务压测必须走全链路网关进五端一起跑而不是压某一个服务。全链路压测前还有一个容易被忽略的检查确认 RocketMQ 的消费端幂等逻辑。发生过一次线上订单重复回调优惠券发了两次商家收到两个出餐通知。这个教训之后我在所有消息消费入口都加了唯一键去重一张 unique_key 索引解决。多做这一步能省掉不止一次事故复盘。希望帮到你。本文还有配套的精品资源点击获取