从单体到微服务:一个电商项目的架构演进与实战拆解
1. 从“大泥球”到“乐高积木”:为什么我们要拆?
大家好,我是老张,一个在电商技术圈摸爬滚打了十来年的老兵。今天想和大家聊聊一个几乎所有技术团队都会遇到的“甜蜜的烦恼”:项目越做越大,代码越来越臃肿,新功能加不动,线上问题查半天。这感觉,是不是特别熟悉?
几年前,我接手了一个典型的电商项目,我们内部戏称它为“黑马商城”。起初,它就是一个标准的单体架构:一个巨大的 Java WAR 包,里面塞满了用户、商品、订单、支付、库存所有模块的代码,共用同一个数据库。开发、测试、部署,一切都“简单粗暴”。创业初期,这种架构简直是“神器”,三五个人,几台服务器,就能快速把产品推向市场,验证商业模式。
但好景不长。随着业务狂奔,团队扩张到几十人,这个“大泥球”开始显现出它的狰狞面目。我印象最深的一次是“双十一”大促,为了加一个“限时秒杀”的功能,我们十几个开发挤在一个代码仓库里,提交冲突不断,合并代码像拆弹。更可怕的是,一个商品模块的 Bug,可能导致整个支付流程挂掉,牵一发而动全身。每次上线都像一场豪赌,运维同学盯着监控屏,手心冒汗。
这时候,我们才痛定思痛,决定拥抱微服务。但微服务不是银弹,它是一套复杂的方法论和工程实践。今天,我就以“黑马商城”这个活生生的例子,带大家走一遍从单体到微服务的完整演进之路。我们不谈空泛的理论,就聊我们踩过的坑、填过的土,以及最终让系统稳定跑起来的那些实打实的技术选型和代码。
2. 庖丁解牛:微服务拆分实战心法
决定要拆,只是万里长征第一步。怎么拆?按什么原则拆?这是决定项目生死的关键。拍脑袋乱拆,只会制造出一堆“分布式单体”,运维复杂度飙升,开发效率却不见涨。
2.1 拆分时机:不是越早越好
很多团队容易陷入一个误区:为了微服务而微服务,项目一开始就搞几十个服务。我的经验是:对于绝大多数项目,尤其是创业型项目,单体起步是更优选择。
- 创业初期(0-1阶段):核心目标是快速验证想法,抢占市场。单体架构开发效率最高,部署简单,调试方便。这时候搞微服务,光服务间通信、部署编排、监控链路这些基础设施就能拖垮小团队。我们的“黑马商城”在最开始的两年,就是坚定的单体拥护者。
- 业务成长期(1-N阶段):当你的团队超过20人,代码库变得庞大,构建时间超过10分钟,不同业务模块的开发节奏严重冲突时,就该认真考虑拆分了。我们的触发点就是那次“秒杀功能”引发的全员加班和线上事故。
- 成熟期(N-∞阶段):对于像淘宝、京东这样体量的系统,或者一开始就明确是大型、长周期、多团队并行开发的项目,资金和人力充足,可以直接采用微服务架构,避免后续伤筋动骨的改造。
2.2 拆分原则:高内聚,低耦合
这是微服务设计的黄金法则,必须刻在脑子里。
- 高内聚:一个服务只做好一件事,并且把它做到极致。比如,“用户服务”就只负责用户的注册、登录、信息管理;“商品服务”只管商品的增删改查、上下架。判断标准是:这个服务内部的业务关联度是否足够高?修改一个需求,是不是只在这个服务内部改动就够了?我们曾经错误地把“用户积分”和“用户基本信息”放到了两个服务里,结果查询一个用户详情要调两个接口,性能差还容易数据不一致,后来果断合并。
- 低耦合:服务之间尽可能独立,通过定义清晰的 API 契约进行通信。避免服务 A 直接访问服务 B 的数据库,这是“耦合”的万恶之源。应该通过服务 B 提供的 HTTP 或 RPC 接口来交互。我们强制规定,任何跨库查询都是红线,一经发现必须重构。
2.3 拆分方式:纵向与横向双管齐下
实际操作中,我们结合了两种拆分方式:
纵向拆分(业务维度):这是最自然的方式,按照业务领域来切分。对照“黑马商城”,我们拆出了:
user-service:用户服务product-service:商品服务order-service:订单服务payment-service:支付服务inventory-service:库存服务 每个服务都有自己的独立数据库(可以是不同的数据库实例,也可以是同一个实例的不同 schema),团队可以独立负责一个或几个服务的全生命周期。
横向拆分(功能维度):在纵向拆分的基础上,我们发现有些功能是多个业务服务都需要的。比如,短信发送、文件上传、地理位置解析。如果每个业务服务都自己实现一套,不仅重复造轮子,维护起来也麻烦。于是我们抽出了:
sms-service:短信服务file-service:文件服务search-service:基于 Elasticsearch 的搜索服务(这个比较特殊,它既是公共服务,也是一个复杂的业务系统) 这些公共服务通过更稳定的接口提供给上层业务服务调用。
一个具体的拆分操作示例:假设我们要从单体中剥离出order-service。
- 代码分离:在单体项目里,新建一个
order-service模块,把原来com.heima.mall.order包下的所有 Controller、Service、Mapper 代码移进去。 - 数据库分离:为订单服务创建独立的数据库
hmall_order,将原库中的t_order,t_order_item等表迁移过去。 - 定义接口:仔细审查订单服务对外提供的功能。比如,创建订单、查询订单详情、取消订单。这些就是它的 API 契约。
- 依赖调整:原来其他模块(如支付)直接调用订单的 Service 方法,现在要改为通过 HTTP 或 Feign 客户端调用
order-service的 API。 - 独立部署:为
order-service配置独立的启动类、配置文件,并能够打包成独立的 JAR 包部署。
这个过程需要耐心和细致的测试,我们当时用了将近一个月的时间,才把第一个服务(用户服务)稳定地拆分出来并上线。
3. 服务通信与治理:让微服务“活”起来
服务拆开了,它们之间怎么“说话”?怎么知道对方在哪?出了问题怎么办?这就进入了微服务架构的核心环节——服务治理。
3.1 服务注册与发现:从“硬编码”到“自动寻址”
在单体时代,调用数据库或者内部模块,地址是写死在配置里的。在微服务世界,服务实例可能动态扩缩容,IP 和端口会变,硬编码行不通了。我们引入了Nacos作为注册中心。
Nacos 在这里扮演了“电话簿”的角色。每个服务启动时,都会向 Nacos 注册自己的信息(服务名、IP、端口、健康状态)。其他服务需要调用它时,不需要知道具体地址,只需要问 Nacos:“user-service在哪里?” Nacos 就会返回一个可用的实例地址列表。
我们使用 Docker 快速部署一个 Nacos 服务端:
docker run -d \ --name nacos \ -e MODE=standalone \ -p 8848:8848 \ -p 9848:9848 \ --restart=always \ nacos/nacos-server:v2.1.0在 Spring Boot 项目中集成 Nacos 客户端非常简单:
- 引入依赖:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> - 配置
application.yml:spring: application: name: user-service # 服务名称 cloud: nacos: discovery: server-addr: localhost:8848 # Nacos服务器地址 - 在启动类上添加
@EnableDiscoveryClient注解。
这样,user-service启动后就会自动注册到 Nacos。其他服务,比如order-service想调用用户服务,就不再需要配置 IP,而是通过服务名来调用。
3.2 声明式 HTTP 客户端:OpenFeign
直接使用 RestTemplate 调用服务名,还需要自己拼接 URL,处理负载均衡,比较麻烦。我们选择了OpenFeign,它能让远程调用像调用本地方法一样简单。
在order-service中,要调用user-service的查询用户接口:
- 引入 Feign 依赖。
- 编写一个接口:
@FeignClient("user-service") // 声明要调用的服务名 public interface UserClient { @GetMapping("/users/{id}") // 声明对方的HTTP方法和路径 UserDTO findById(@PathVariable("id") Long id); } - 在需要的地方直接注入
UserClient并调用findById方法。
Feign 会自动从 Nacos 获取user-service的实例列表,并利用内置的 Ribbon 实现负载均衡(比如轮询),将请求分发到不同的实例上。这大大简化了开发。
3.3 统一的入口:API 网关
服务多了,对外暴露的地址也多了,管理起来混乱,且一些横切面逻辑(如鉴权、限流、日志)每个服务都要写一遍。我们引入了Spring Cloud Gateway作为 API 网关。
网关是所有外部请求的单一入口。它的核心功能是路由和过滤。
路由:根据请求路径,将请求转发到对应的微服务。
spring: cloud: gateway: routes: - id: user-service-route uri: lb://user-service # lb:// 表示从注册中心负载均衡 predicates: - Path=/api/users/** filters: - StripPrefix=1 # 去掉路径前缀 /api这样,外部访问
http://网关地址/api/users/1的请求,会被网关转发到user-service的/users/1接口。过滤:我们利用网关的GlobalFilter实现了全局的登录校验。所有需要认证的请求,先在网关层校验 Token 的有效性,无效则直接返回 401,有效则解析出用户信息,放入请求头传递给下游服务。这样,业务服务就无需再关心鉴权逻辑,代码更纯粹。
3.4 动态配置管理:一改全生效
微服务可能有几十上百个实例,修改一个配置(比如数据库连接池大小、开关某个功能)难道要一个个去改配置文件然后重启吗?当然不。Nacos 除了做注册中心,还是一个强大的配置中心。
我们将每个服务的application.yml中需要动态调整的部分,抽取到 Nacos 的配置管理中。服务启动时,会从 Nacos 拉取配置。当我们在 Nacos 控制台修改配置并发布后,Nacos 会通知所有监听该配置的服务,服务会实时更新内存中的配置,实现热更新,无需重启。
# 在 bootstrap.yml 中配置(优先级高于 application.yml) spring: cloud: nacos: config: server-addr: localhost:8848 file-extension: yaml shared-configs: # 可以共享一些公共配置 ->docker run --name seata-server \ -p 8091:8091 \ -p 7091:7091 \ -e SEATA_IP=你的服务器IP \ -v /your_path/seata/config:/seata-server/resources \ --network your_network \ -d seataio/seata-server:1.5.2seata.tx-service-group和seata.service.vgroup-mapping指向 TC 服务器。@GlobalTransactional注解。@Service public class OrderServiceImpl implements OrderService { @GlobalTransactional // 开启全局事务 public void createOrder(OrderDTO orderDTO) { // 1. 本地创建订单(操作order-service数据库) orderMapper.insert(order); // 2. 远程调用:扣减库存(操作inventory-service数据库) inventoryClient.deduct(orderDTO.getSkuId(), orderDTO.getNum()); // 3. 远程调用:使用优惠券(操作coupon-service数据库) couponClient.use(orderDTO.getCouponId()); // 如果任何一步失败,所有操作都会回滚 } }5.2 最终一致性:拥抱消息队列
对于非核心的、允许短暂延迟的业务,我们更推荐使用最终一致性,而实现最终一致性的利器就是消息队列(MQ)。我们选择了RabbitMQ,看重其成熟、稳定和灵活的路由模型。
典型场景:订单支付成功后的后续处理。用户支付成功后,需要:
- 更新订单状态为“已支付”。
- 给用户发放积分。
- 通知仓库发货。
- 发送支付成功短信。
如果全部在支付服务里同步调用,支付接口响应会非常慢,且任何一个下游服务失败都会导致支付失败,体验极差。我们改造为异步消息驱动:
- 支付服务在本地事务中更新订单状态为“已支付”后,向 RabbitMQ 发送一条“支付成功”消息。只要消息成功发出,就可以立即返回给用户“支付成功”。
- 积分服务、仓储服务、短信服务都监听同一个“支付成功”队列(或通过交换机路由)。它们各自消费消息,执行自己的业务逻辑。
- 即使某个服务(比如短信服务)暂时挂了,消息也会在 RabbitMQ 中持久化,等其恢复后继续消费,保证业务最终被执行。
这里有几个关键保障机制:
- 生产者确认:确保消息成功发送到 Broker(RabbitMQ服务器)。
- 消费者确认(ACK):确保消息被消费者成功处理后才从队列删除,防止消息丢失。
- 死信队列与重试:如果消费者处理失败,可以将消息投入死信队列,由监控系统报警,或延迟后重新投递重试。
- 业务幂等性:因为网络问题可能导致消息重复投递,所以消费者逻辑必须保证幂等。比如,为“支付成功”消息携带一个全局唯一的支付流水号,积分服务在处理前先查一下这个流水号是否已处理过。
我们通过 RabbitMQ 的Delayed Message Exchange插件,还实现了“取消超时未支付订单”的功能:下单时发送一条延迟30分钟的消息,如果30分钟后消费者(订单服务)发现订单状态仍是“待支付”,则执行取消逻辑。
6. 搜索与数据分析:Elasticsearch 登场
电商离不开搜索。在单体时代,我们直接用数据库的LIKE语句,性能差、功能弱。拆分后,我们构建了独立的search-service,其核心是Elasticsearch(ES)。
为什么用 ES?
- 近实时搜索:毫秒级返回海量数据下的搜索结果。
- 全文检索与分词:支持对商品标题、描述进行智能分词和模糊匹配。
- 复杂聚合:轻松实现按品牌、分类、价格区间等多维度聚合统计,用于生成筛选条件和数据分析。
我们的实践:
- 数据同步:商品服务在商品信息变更时,除了写数据库,还会发一条 MQ 消息。搜索服务消费消息,将最新的商品数据更新到 ES 索引中。我们用的是“双写”结合“定时补偿”的策略,保证 ES 与数据库的最终一致。
- 索引设计:精心设计 Mapping,决定哪些字段需要分词(
text),哪些需要精确匹配(keyword),哪些只存储不用于搜索("index": false)。PUT /items { "mappings": { "properties": { "name": { "type": "text", "analyzer": "ik_max_word" }, // 商品名,用IK最细粒度分词 "brand": { "type": "keyword" }, // 品牌,精确匹配 "price": { "type": "integer" }, // 价格,用于范围和排序 "category": { "type": "keyword" } } } } - Java 客户端调用:使用
RestHighLevelClient构建复杂的 DSL 查询,包括布尔查询、范围过滤、排序分页和高亮显示。SearchRequest request = new SearchRequest("items"); request.source().query(QueryBuilders.boolQuery() .must(QueryBuilders.matchQuery("name", "华为手机")) .filter(QueryBuilders.rangeQuery("price").gte(200000).lte(500000)) .filter(QueryBuilders.termQuery("brand.keyword", "华为")) ).sort("price", SortOrder.ASC) .from(0).size(10) .highlighter(new HighlightBuilder().field("name"));
7. 容器化部署:Docker 与 Docker Compose
服务拆分成十几个甚至几十个后,部署成了噩梦。每个服务环境依赖(JDK版本、配置文件)可能不同,手工部署极易出错。我们全面转向了Docker容器化部署。
Docker 带来的好处:
- 环境一致性:开发、测试、生产环境使用完全相同的镜像,杜绝了“在我机器上是好的”这类问题。
- 快速部署与扩缩容:一个
docker run命令就能启动一个服务。结合编排工具,可以轻松实现服务的横向扩展。 - 资源隔离:每个服务运行在独立的容器中,互不干扰。
我们为每个微服务编写了Dockerfile,将编译好的 JAR 包和运行环境打包成镜像。然后使用Docker Compose来定义和运行整个应用集群。
# docker-compose.yml 示例片段 version: '3.8' services: nacos: image: nacos/nacos-server:v2.1.0 container_name: nacos ports: - "8848:8848" networks: - hm-net mysql: image: mysql:8.0 container_name: mysql environment: MYSQL_ROOT_PASSWORD: root volumes: - ./mysql/data:/var/lib/mysql networks: - hm-net user-service: build: ./user-service # 根据Dockerfile构建镜像 container_name: user-service depends_on: - nacos - mysql environment: SPRING_PROFILES_ACTIVE: docker networks: - hm-net order-service: build: ./order-service container_name: order-service depends_on: - nacos - mysql environment: SPRING_PROFILES_ACTIVE: docker networks: - hm-net # ... 其他服务 networks: hm-net: driver: bridge只需要在服务器上执行docker-compose up -d,所有服务就会按依赖顺序启动,形成一个完整的、隔离的微服务运行环境。这极大地简化了运维复杂度。
回头看“黑马商城”的架构演进,从单体到微服务,绝不是简单地把代码分到不同的文件夹。它是一次从开发模式、团队组织到运维体系的全面升级。拆分的过程充满了挑战,需要谨慎的决策、细致的实施和配套的自动化工具。但当你看到每个小团队可以独立开发、测试、部署自己的服务,发布频率从月提高到周甚至天,系统稳定性不降反升时,你会觉得这一切的付出都是值得的。微服务不是终点,而是一个更适合复杂业务和快速迭代的起点。希望我们踩过的这些坑,能为你点亮前行的路。
