简介:面向电商技术架构师、微服务开发者与运维人员,这份PDF集中梳理了基于微服务的电商中台架构与上线实践。内容以某云计算平台整体建设为背景,覆盖混合云管理、IaaS资源池及Rancher容器云三大部分,并围绕服务注册发现、API网关Kong、消息队列RocketMQ与Kafka、日志平台ELK等核心组件展开。资源为单个PDF文件,大小仅1.06MB,便于随时查阅与分享。文中还重点总结了真实上线的关键决策,包括Vxlan网络性能优化、RBD与NFS存储选型、Redis与数据库等有状态组件的数据持久化问题,以及基于Rancher API实现无感知应用更新的publish-helper工具思路。这些一线架构经验对设计电商中台、规划容器化改造或评估混合云方案均具有直接参考价值。目前已有163人学习,适合有一定微服务基础并希望了解生产级落地方案的读者。
1. 微服务与电商中台:这份架构PDF到底在解决谁的什么问题
把微服务、电商中台、架构这三个词放到一起,往往是会议室里一整天讨论不出结果的源头。翻过不少电商中台相关的架构文档,坦率说,最有价值的通常不是那张画满箭头的总体架构图,而是它把“哪些东西必须拆成独立服务、哪些拆了就是给自己挖坑”讲清楚了。这篇文章要解决的,是单体商城已经跑出上百万订单后,想往中台化演进时最常问的几个现实问题:服务按什么拆、订单库存数据怎么落、分布式下怎么保证下单不超卖、大促前怎么验证这套架构扛得住。适合正在做中台拆分、或者准备把老商城重构到微服务体系的开发与架构师。我会按从业者的常见方案来讲,从拆分依据一路推到压测验证,尽量把决策链条补齐。
2. 先定边界再动代码:中台服务拆分的三个依据与一次反推
微服务拆分最忌讳的就是拍脑袋。业务部门说“订单要独立”,技术负责人说“用户要独立”,结果拆出来几十个服务,每个服务只有一张表、两个接口,调用链却绕了五个来回。做电商中台第一步不是写代码,而是把“边界”两个字想清楚。
2.1 业务域的划分:商品、订单、库存、支付为什么是四个独立域
电商中台最常见的拆分方式是按业务域拆,商品域、订单域、库存域、支付域、会员域、营销域。这套划分的逻辑不是“表多了就拆”,而是看业务变化频率和依赖方向。商品域是基础数据,相对稳定;订单域是交易主链路,变动频繁;库存域对实时性要求极高,而且同时被销售前台、采购后台、仓库系统三头调用;支付域则是外部依赖最多的一个域,微信、支付宝、银行渠道一个都不能少。
我一般会用一个很土的标准来判断拆分是否成立:某个域的数据如果同时被三个以上其他域直接读写数据库,就值得拆出来独立成服务。反过来,如果拆完以后,A服务要同步调B服务、B服务又要同步调C服务,才能完成一次页面展示,那说明边界画错了,本该是一个聚合根里的数据被硬生生劈开了。
电商中台里最容易拆错的是“营销域”。优惠券、满减、秒杀看起来是独立业务,但实际下单时,订单域必须实时校验券是否可用、计算优惠后金额,库存域又要防超卖。如果营销域做成一个完全独立的服务,下单接口就会变成“先调营销、再调库存、再创建订单、再扣减”,四个服务一次串行调用,性能直线下降。常见做法是把营销规则沉淀成配置和计算接口,但促销活动的“结果”必须落到订单上下文里。
2.2 从调用链反推服务粒度:一个查询跨几个服务就算拆坏了
这里有一个可量化的参考标准。我会在代码评审时盯两个数字:核心链路(比如下单、支付回调)上的同步调用不能超过三个服务,普通页面查询的同步调用不能超过两个服务。超过这个数,先别急着加缓存,回去看是不是服务拆细了。用一段伪代码说明这个判断逻辑。
// 订单详情页查询:controller -> service -> 聚合服务 public OrderDetailVO getOrderDetail(String orderId) { // 反例:这里有4次RPC调用 UserInfo user = userClient.getById(orderId); // RPC 1 OrderInfo order = orderClient.getById(orderId); // RPC 2 List<ItemInfo> items = itemClient.listByOrderId(orderId); // RPC 3 PromotionInfo promo = promoClient.getByOrderId(orderId); // RPC 4 return assemble(user, order, items, promo); }这段代码乍看没什么问题,但它反映出订单详情页的数据被分散在了四个服务里。真实场景里这四次RPC各有各的超时时间、重试策略和熔断状态,任何一个服务抖动,页面就报错。我的建议是订单详情这种高频读写场景,要么通过订单快照落库,要么在订单域内冗余一份用户昵称、商品标题、促销名称的只读副本。代码逻辑说明:上面的反例是典型的“查询穿透”,真正落地的做法是在订单服务里维护一个order_detail_ext扩展表,下单时把详情页需要的冗余字段同步写入,查询时一次本地查询搞定。参数说明:这里的同步调用次数上限,指的是核心链路内的RPC数量,异步消息通知、事件订阅不算在内。
2.3 中台与前台系统的边界:不是所有的功能都要收归中台
做中台容易走另一个极端——把所有能力都收进中台,前台系统只剩一个壳。电商场景里,中台应当收敛的是“稳定且跨场景复用的能力”,比如订单创建、库存扣减、支付路由、商品基础信息。而前台需要快速迭代的部分,比如直播间秒杀玩法、内容社区的拼团活动,就不应该被中台绑定。
区分的方法是看“这个能力是否被多个前台场景共享”。分享一个真实教训:早年我们试图把拼团、秒杀、预售全部收进促销中台,结果促销中台成了整个系统里改动最频繁的服务,几乎每周发版一次,而且每次改动都要回归所有促销类型。后来把拼团这种强前台属性的玩法下沉回业务前台,中台只保留优惠券、满减、单品降价三种通用促销模板。中台的边界不是越宽越好,而是改得越少越好。
3. 核心链路的数据模型与接口设计:订单、库存、商品怎么落库才算稳
服务边界定完之后,紧接着就是数据模型。微服务架构下,数据库是跟着服务走的,服务之间禁止直接访问对方的库,所有数据交互只能走接口或消息。这一点大家都知道,但真到拆表的时候,最容易翻车的是“拆完表之后,跨服务的关联查询怎么做”。
3.1 订单中心的表结构拆分:主表、明细表、支付单怎么各司其职
按典型电商订单域的落库方案,订单数据至少要拆成四类表:订单主表(订单号、用户ID、订单状态、总金额)、订单明细表(SKU、数量、单价、优惠分摊)、支付单表(支付流水号、渠道、金额、支付状态)、订单事件表(状态变更记录)。订单主表是整个交易链路的锚点,状态字段的取值必须严格控制,我见过最混乱的项目,订单状态用的是字符串自由填,结果“已支付”和“支付成功”两个值并存,后面的对账脚本整天报警。
-- 订单主表(只存订单级信息) CREATE TABLE `order_main` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号,业务唯一', `user_id` bigint NOT NULL COMMENT '用户ID', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总额', `status` tinyint NOT NULL COMMENT '10待支付 20已支付 30已发货 40已完成 50已取消', `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_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';逻辑说明:主表的核心是唯一键order_no,这个字段必须由订单服务自己生成,常见做法是用“日期+机房ID+自增序列”拼成雪花ID,而不是依赖数据库自增。这样做的原因是微服务拆库后,订单号要具备跨库的唯一性和趋势递增性,方便后续分库分表。参数说明:status用tinyint代替字符串是为了索引效率和避免无序值,状态机的流转必须在代码里统一收敛,不能在不同服务里各写一套判断。订单明细表同样要建唯一键,但对账时通常用“订单号+SKU_ID”作为联合唯一约束,防止并发下重复插入明细。
3.2 商品域:SPU、SKU与库存扣减的模型边界
商品域的核心是SPU(标准产品单元)和SKU(库存量单位)的区分。SPU是“商品”的概念,比如“iPhone 15 Pro”,SKU是“具体可下单的规格”,比如“iPhone 15 Pro 黑色 256G”。SPU属性在前台展示时是聚合的,SKU属性在下单时要精确锁定。如果模型里只有SKU没有SPU,商品详情页的规格筛选就会非常痛苦;如果只有SPU没有SKU,下单时就无法确定用户到底买了哪个具体规格。
库存扣减是商品域和订单域交叉的核心动作。常见做法是库存独立成库存服务,对外只暴露“预占、确认扣减、释放”三个接口。这里是电商中台最典型的坑:不要在订单表里直接减库存,因为订单可能被取消,而取消时库存要回补;也不要同步调库存服务,因为一次大促瞬间的并发会直接把库存服务打挂。业内通用的方案是通过消息队列异步扣减,用订单状态驱动的可靠消息来保证最终一致。商品域的表结构通常是SPU表、SKU表和库存表三张,SKU表存价格、编码、状态,库存表存可售量、预占量、实际库存。预占量很关键,它记录的是“被下单但还没支付”的数量,释放时才能精确回补。
3.3 用DDD聚合避免跨服务join:下单时的数据一致性怎么边界
微服务下禁止跨服务join,这句话说起来轻松,做起来全是取舍。最典型的下单场景,同时涉及会员服务(校验用户状态)、商品服务(查询SKU信息)、订单服务(创建订单)、库存服务(扣减库存)、营销服务(计算优惠)。如果把每个服务的数据都拆干净,订单服务就要拿着SKU_ID去商品服务查价格、拿优惠券去营销服务算金额,一次下单串起四五次RPC。
DDD在这里给出的解法是“聚合”。订单聚合根内部包含订单主表、订单明细表、订单金额快照、促销快照,下单时把需要的数据一次性带入订单服务本地,落库后再通过领域事件通知其他服务。用白话讲,就是“先把订单这个聚合根在本地闭环建好,其他服务通过订阅事件来更新自己的数据”。这样做的代价是订单表里会冗余很多字段,比如商品标题、商品主图、SKU规格、促销名称、优惠金额,但换来的是订单详情页一次本地查询就能返回全部数据。做中台架构,必须接受这种有意的数据冗余,它和数据库设计范式的理念刚好相反,但在微服务场景下是正确的取舍。
4. 用Spring Cloud Alibaba搭一套最小中台骨架:Nacos、网关与业务服务的落地顺序
边界和数据模型都理清楚之后,就要落到工程上了。微服务的技术栈选择,现在国内电商中台的主流方案是Spring Cloud Alibaba,原因很实在:Nacos同时承担注册中心和配置中心,Sentinel做流控熔断,Seata解决分布式事务,这一套和国内互联网公司的运维习惯、部署方式是最匹配的。
4.1 为什么不选原生Spring Cloud:注册中心与配置中心的一体化优势
原生Spring Cloud Eureka只做注册中心,配置中心要额外搭Spring Cloud Config,还要再引入Bus消息总线才能实现配置动态刷新。三个组件三套运维,对中小团队来说负担不小。Spring Cloud Alibaba把Nacos一个组件干了两件事,服务注册发现和配置管理统一治理,控制台里能直接看到服务健康状态、配置变更历史,运维成本直接砍半。我见过不少团队从原生Spring Cloud迁移到Alibaba体系,迁移的核心理由基本都指向这个。
另一个原因是Spring Cloud Alibaba和Spring Boot、Spring Cloud的版本对应关系比较清晰,选一个兼容的版本组合就能跑通。以我常用的2022.x分支为例,对应的Spring Boot是2.7.x,Spring Cloud是2021.0.x。选版本最怕的是乱配,网上搜到的最新版本组合不一定兼容,这里建议直接参考官方发布的版本说明,不要自己拼。
4.2 最小工程结构:从Nacos到业务服务需要准备哪些模块
一个可运行的最小电商中台骨架通常包含这些工程:父工程(管理依赖版本)、Nacos服务端(独立部署)、网关服务gateway-service、订单服务order-service、商品服务product-service、库存服务stock-service、公共服务common(存放DTO、工具类、统一返回结果)。
工程目录结构如下:
mall-middle/ ├── pom.xml // 父工程,统一依赖管理 ├── gateway-service/ // 网关,端口 8080 ├── order-service/ // 订单服务,端口 8101 ├── product-service/ // 商品服务,端口 8102 ├── stock-service/ // 库存服务,端口 8103 └── common/ // 公共服务,无端口逻辑说明:父工程统一管理Spring Boot、Spring Cloud、Spring Cloud Alibaba的版本,子工程不再各自声明版本号,避免依赖冲突。这是多模块微服务工程最基本也是最重要的一条纪律——版本一定在父工程锁死。参数说明:端口规划要留出余量,网关用8080,业务服务从8101起跳,后续新增服务不要占用系统预留端口。Nacos默认端口是8848,控制台是8848/nacos。
4.3 网关路由与统一鉴权:三个必调参数
网关是整个中台的流量入口,电商场景里它至少要做三件事:路由转发、统一鉴权、限流。路由配置里最常踩的坑是“路径重写”,比如前端请求/api/order/xxx,而订单服务的接口路径是/order/xxx,如果网关不做StripPrefix处理,请求就会404。
spring: cloud: gateway: routes: - id: order-route uri: lb://order-service predicates: - Path=/api/order/** filters: - StripPrefix=1 - id: product-route uri: lb://product-service predicates: - Path=/api/product/** filters: - StripPrefix=1逻辑说明:lb://order-service表示从Nacos注册中心按服务名负载均衡,这里的order-service必须和order-service应用在Nacos里的注册名完全一致,拼错一个字符请求就会转发失败。StripPrefix=1的含义是去掉路径里的第一段,/api/order/list会变成/order/list转发给下游服务。参数说明:网关的超时时间要单独设置,Spring Cloud Gateway默认的HTTP请求超时可能只有几秒,电商下单链路里一个慢SQL卡一下就可能触发504,建议把spring.cloud.gateway.httpclient.connect-timeout配成5000ms、response-timeout配成10s,再配合Sentinel的熔断规则兜底。统一鉴权在网关做Filter,但要注意网关Filter里不能做太重的逻辑,只校验token存在性和有效期,具体的用户权限查询放在业务服务里,否则网关会变成新的性能瓶颈。
5. 电商中台微服务避坑:分布式事务、幂等与跨服务分页的5个现场
微服务架构下,出问题几乎都是出在“数据一致性”和“调用链异常”上。这一章写五个真实踩坑记录,都是电商中台演进过程中反复出现的,每条按“现象、原因、解决”来写。
5.1 下单扣库存的分布式事务:Seata AT模式的血泪经验
现象:用户下单时,订单创建成功但库存扣减失败,用户看到订单已生成,仓库却没货可发;或者库存扣减成功但订单创建失败,库存白白被占用,用户没下单成功却买不了别的商品。
原因:拆成订单服务和库存服务以后,原本单体数据库里一个事务能搞定的事情,现在跨了两个库两个服务,本地事务管不住对方。
解决:引入Seata做分布式事务,用的AT模式。订单服务作为事务发起方,开启全局事务,库存服务的本地事务注册到同一个全局事务里,任一分支失败,全局回滚。这里有一个参数特别关键:seata.tx-service-group必须保持一致,事务分组名配错了,服务之间找不到事务协调器,全局事务根本不会生效。另外,Seata AT模式要求数据库表必须有主键,且建议给业务表加undo_log表,这个表是Seata回滚的依据,漏建了会导致回滚时报“undo_log不存在”的错误。
5.2 支付回调的幂等:重复通知导致超卖
现象:支付渠道回调同一个支付结果两次,订单服务把订单从“待支付”更新成“已支付”执行了两次,库存扣减了两次,结果实际只支付了一个订单,库存却少了双份,超卖就是这么来的。
原因:支付回调天然会重试,渠道方为了保证消息可靠送达,会按一定策略重复推送结果。如果回调处理逻辑没有幂等保护,重复消费就必然发生。
解决:在支付回调处理接口里加“业务幂等”,核心是唯一约束加状态判断。支付流水表里payment_no建唯一索引,处理回调前先查支付单状态,如果是“已支付”直接返回成功,不再执行库存扣减。同时,库存扣减要走消息队列的消费幂等,消费端记录“已消费的消息ID”,重复消息直接丢弃。这里要特别提醒:不要依赖Redis的setnx做幂等就完事,因为Redis宕机或key过期后,重复消息还是会进来,必须数据库唯一索引做兜底。
5.3 跨服务分页查询:订单列表页越查越慢
现象:后台订单列表页,按用户昵称或商品名称筛选订单,查询耗时从几百毫秒飙到几秒,数据库CPU被打满。负责订单服务的同事很委屈:“订单表里没用户昵称字段,我只能先调用户服务查出用户ID,再回订单表查订单,一次查询要循环几十次RPC。”
原因:这是典型的“服务拆了,但查询需求没跟着拆”。订单列表页的筛选条件很多来自其他域,比如用户昵称、商品名称,而数据库不允许跨库join,只能循环调接口拼数据。
解决:这个问题的标准做法是建“查询聚合层”,在订单服务里同步一份用户昵称、商品名称到宽表或搜索引擎。订单创建时,通过消息队列把用户昵称、商品标题这些冗余字段推送到订单宽表;后台列表页直接查宽表,不再实时跨服务拉取。宽表的更新是异步的,允许最多几秒的延迟,这对管理后台完全够用。代价是订单服务要多维护一张扩展表,但它能把列表页查询的RPC次数从几十次降成一次。
5.4 Nacos注册了但调用不通:服务名与网络策略的玄学
现象:新服务启动后,Nacos控制台能看到服务列表里有它,但其他服务调用时总是报Connection refused,偶尔又能通一次,跟撞大运一样。
原因:第一层原因是服务名大小写不一致,Nacos注册时spring.application.name配的是order-service,而Feign客户端写的服务名是Order-Service,注册中心虽然显示正常,但实际路由匹配不上。第二层原因是服务所在主机的内网IP没有正确上报,Nacos拿到的IP是127.0.0.1,其他服务当然连不上。
解决:给服务配置显式的IP上报,在bootstrap.yml里加spring.cloud.nacos.discovery.ip,指向服务所在机器的内网IP,同时检查Feign调用方的服务名和注册名完全一致,大小写和连字符都要对。这一条排查看上去玄学,其实多半是IP上报或服务名匹配的问题。
5.5 配置中心改了不生效:@RefreshScope的边界
现象:开发环境在Nacos配置中心改了限流阈值或开关配置,业务服务控制台打了日志说配置已更新,但实际行为没变,限流还是按旧值生效。
原因:配置中心的动态刷新只对加了@RefreshScope注解的Bean有效。如果业务的限流组件是全局单例、没有加这个注解,那么配置刷新后它拿到的还是内存里旧的对象。
解决:给配置属性类加@RefreshScope,并确认配置变更的事件能够被业务服务订阅。另外,配置中心本身要配好命名空间和数据ID,环境隔离不要靠注释,而是靠spring.cloud.nacos.config.namespace区分dev/prod,否则很容易出现改的是测试环境的配置,生产环境一直不生效。
6. 把架构图落到能扛住大促:核心链路压测与容量评估清单
架构方案写得再好,没有经过压测验证,就是一张好看的图。电商中台上线前最关键的一步,是对“下单->扣库存->支付回调”这条核心链路做压力测试,并围绕结果调整参数。压测不是跑一遍就行的,要测出系统的拐点在哪里。
6.1 用JMeter做下单链路压测的最小脚本
建议先测单服务的极限,再测全链路。单服务测试的意义是摸清每个服务的最大吞吐和瓶颈点,避免全链路压测时问题互相掩盖。下面是一个最小JMeter脚本的JSON片段,模拟下单请求。
{ "httpTest": { "protocol": "http", "server": "gateway.mall.local", "port": 8080, "path": "/api/order/create", "method": "POST", "body": { "userId": 10001, "skuId": 20001, "quantity": 1, "couponId": 0 }, "headers": { "Content-Type": "application/json", "Authorization": "Bearer token" } }, "threadGroup": { "threads": 100, "rampUpSeconds": 10, "loopCount": 100 } }逻辑说明:threads设置为100并发、持续100轮,也就是总共一万个请求,这足够暴露出线程池和数据库连接池的瓶颈。压测时建议从50并发起步,逐步增加到100、200、500,每档记录一次RT和错误率。参数说明:rampUpSeconds是线程启动时间,设成10秒是让流量缓慢爬坡,不要一把梭全量打上去,否则系统瞬间被打满,你看不出它是被压垮的还是本来就撑不住。
6.2 三个必须盯的指标曲线
第一是接口RT曲线。如果RT从一开始就匀速上升,说明系统在排队;如果RT突然从100ms跳到2000ms,说明某个资源被打满了,通常是数据库连接池或线程池。第二是GC曲线。下单链路要关注Young GC频率和耗时,如果每秒Young GC超过5次,或单次GC超过100ms,说明订单服务的堆分配压力过大,要么调大堆内存,要么检查代码里是否有大对象频繁创建。第三是线程池活跃线程数。如果核心线程数一直顶满且队列在增长,说明消费速度跟不上生产速度,加机器只是治标,要查下游DB慢查询或锁等待。
6.3 大促前一天的架构检查清单
下面的清单是每次大促前我都会过一遍的项目,它不复杂,但每一条都曾经在真实环境里出过事。
| 检查项 | 检查标准 | 失败时的典型症状 |
|---|---|---|
| Nacos注册的服务数 | 与实际部署实例数一致 | 流量倾斜到少量实例,部分机器过载 |
| 核心服务的最大线程池 | Tomcat线程数和队列总和不超过数据库连接池上限 | 线程池排队导致RT飙升 |
| 数据库连接池水位 | 最大连接数至少是QPS预估值的3倍以上 | 连接获取超时,接口报错 |
| Redis热key分布 | 单key访问量低于整体QPS的30% | 缓存节点CPU打满,请求穿透DB |
| 消息队列堆积 | 核心消息积压量不超过10万条 | 库存回补延迟,超卖漏出 |
| 网关限流阈值 | 单实例QPS限制为压测拐点值的70% | 流量突发冲破下游,雪崩 |
压测之后还有一个动作容易被人忽略:容量冗余。所谓容量冗余,就是压测出来的最高水位和线上预估流量之间要留出至少30%的余量。预估流量是10万QPS,压测最大只能扛到7万,那就要加机器或者调优,而不是直接上线赌一把。电商大促的流量模型是陡峭的尖峰,冗余留少了,系统会在流量最高点崩掉,而那时候你没有任何后悔药可以吃。
最后说一个我的习惯:每次压测完,我会把瓶颈原因和参数调整记录到架构文档里,包括线程池大小、数据库连接池、Nacos配置变更的完整记录。这套架构演进到后期,文档里的“为什么这么调”比架构图本身更值钱。翻车不可怕,可怕的是第二次在同一个地方翻车。希望这一篇的方案和排查思路帮到你,让你的中台拆分少走一段弯路。
本文还有配套的精品资源,点击获取