上一篇文章梳理了 Spring Cloud 各个组件的作用和学习路径。这次想聊聊更实际的问题:当你真正要把一个系统拆成微服务时,具体该怎么做?
我参考了github各个开源老师的 Spring Cloud 实战仓库结合其中的完整项目案例,整理了一套从服务拆分、数据库设计到分布式事务的实战思路。
一、服务拆分:怎么拆才合理
1.1 拆分的核心原则
微服务拆分没有标准答案,但有几个基本原则可以参考:
按业务边界拆分
每个服务对应一个明确的业务领域,比如用户服务、订单服务、商品服务。这样服务之间的职责清晰,团队可以独立开发和部署。
高内聚、低耦合
服务内部的功能紧密相关,服务之间的依赖尽量少。如果两个服务频繁互相调用,可能说明拆分得不够合理。
数据独立性
每个服务应该有自己的数据库,避免多个服务直接操作同一张表。这是微服务和传统 SOA 的重要区别。
1.2 实战项目的服务拆分案例
参考仓库中的房产项目(fangjia-fsh),它的拆分方式如下:
| 服务 | 职责 | 对应模块 |
|---|---|---|
| API 网关 | 统一入口、路由转发、鉴权 | fangjia-fsh-api |
| 用户服务 | 用户注册、登录、个人信息 | fangjia-fsh-user-service |
| 房产服务 | 房源信息、搜索、展示 | fangjia-fsh-house-service |
| 替换服务 | 房源替换逻辑 | fangjia-fsh-substitution-service |
| 认证服务 | 服务间调用鉴权、Token 颁发 | fangjia-auth-service |
| 注册中心 | 服务发现与注册 | fangjia-eureka |
| 监控服务 | 服务健康状态监控 | fangjia-boot-admin |
拆分要点:
- 用户服务和房产服务是核心业务,独立拆分
- 认证逻辑抽离成独立服务,避免每个服务重复实现
- API 网关作为统一入口,对外暴露 REST 接口,对内路由到具体服务
二、数据库设计:每个服务一个数据库
2.1 数据库拆分的必要性
微服务架构中,每个服务应该拥有独立的数据库。这样做的好处:
- 服务之间解耦,一个服务的数据库变更不会影响其他服务
- 可以根据服务特点选择不同的数据库类型(MySQL、MongoDB、Redis 等)
- 便于独立扩展,热点服务可以单独做读写分离或分库分表
2.2 读写分离实战
当读请求远多于写请求时,读写分离是常用的优化手段。仓库中的 fangjia-sjdbc-read-write 模块展示了基于 ShardingJDBC 的实现:
yaml
spring:
shardingsphere:
datasource:
names: master, slave
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/master_db
username: root
password: 123456
slave:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/slave_db
username: root
password: 123456
masterslave:
name: ms
master-data-source-name: master
slave-data-source-names: slave
关键点:
- 写操作路由到主库(master)
- 读操作路由到从库(slave)
- 主从同步由 MySQL 自身机制保证
2.3 分库分表实战
当单表数据量过大(通常超过千万级)时,需要考虑分库分表。仓库提供了两个示例:
分表(sharding-table):同一数据库内,按某个字段将数据分散到多张表
yaml
spring:
shardingsphere:
sharding:
tables:
t_order:
actual-data-nodes: ds0.t_order_${0..9}
table-strategy:
inline:
sharding-column: order_id
algorithm-expression: t_order_${order_id % 10}
分库分表(sharding-db-table):数据分散到多个数据库的多张表
yaml
spring:
shardingsphere:
sharding:
default-database-strategy:
inline:
sharding-column: user_id
algorithm-expression: ds${user_id % 2}
tables:
t_order:
actual-data-nodes: ds${0..1}.t_order_${0..9}
分片策略选择:
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 取模分片 | 数据分布均匀的场景 | 负载均衡 | 扩容时需要迁移数据 |
| 范围分片 | 按时间、ID 范围查询的场景 | 扩容方便 | 可能存在热点 |
| 哈希分片 | 需要均匀分布的场景 | 避免热点 | 范围查询效率低 |
三、分布式事务:怎么保证数据一致性
3.1 为什么需要分布式事务
微服务架构下,一个业务操作可能涉及多个服务。比如下单操作:
- 订单服务创建订单
- 库存服务扣减库存
- 用户服务扣减余额
这三个操作必须同时成功或同时失败,否则会出现数据不一致。
3.2 分布式事务解决方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 2PC | 两阶段提交,协调者统一管理 | 强一致性 | 性能差、协调者单点故障 | 对一致性要求极高的金融场景 |
| TCC | Try-Confirm-Cancel,业务层实现补偿 | 性能较好 | 业务侵入性强,开发成本高 | 电商、支付等高并发场景 |
| ** Saga** | 长事务拆分,失败时执行补偿操作 | 性能好 | 最终一致性,补偿逻辑复杂 | 业务流程长、需要异步处理的场景 |
| 可靠消息 | 通过消息队列保证最终一致性 | 解耦、性能好 | 实现复杂,需要处理幂等 | 大多数异步场景 |
3.3 实战:基于可靠消息的分布式事务
仓库中的 transaction-mq-service 模块实现了一套可靠消息服务,核心思路:
scss
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 业务服务 │────▶│ 消息服务 │────▶│ 消息队列 │
│ (发起方) │ │ (可靠消息) │ │ (RocketMQ) │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
▼
┌─────────────┐
│ 消费服务 │
│ (接收方) │
└─────────────┘
核心流程:
- 业务服务 执行业务操作,同时向消息服务发送预消息
- 消息服务将消息状态设为"待确认",返回给业务服务
- 业务服务执行业务逻辑,成功后调用消息服务确认消息
- 消息服务将消息投递到 MQ
- 消费服务消费消息,执行业务操作
消息表设计:
sql
CREATE TABLE transaction_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
message_id VARCHAR(64) NOT NULL UNIQUE,
message_body TEXT NOT NULL,
destination VARCHAR(255) NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0:待发送 1:已发送 2:消费成功 3:消费失败
send_times INT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_time (status, create_time)
);
定时补偿机制:
java
@Component
public class MessageRetryTask {
@Scheduled(fixedRate = 60000) // 每分钟执行
public void retry() {
// 查询待发送和发送失败的消息
List<TransactionMessage> messages = messageService
.findByStatusAndTime(MessageStatus.PENDING, 5);
for (TransactionMessage msg : messages) {
if (msg.getSendTimes() > 3) {
// 超过重试次数,人工介入
msg.setStatus(MessageStatus.FAILED);
messageService.update(msg);
continue;
}
// 重新发送
rocketMQTemplate.asyncSend(msg.getDestination(),
msg.getMessageBody(), new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
msg.setStatus(MessageStatus.SENT);
messageService.update(msg);
}
@Override
public void onException(Throwable e) {
msg.setSendTimes(msg.getSendTimes() + 1);
messageService.update(msg);
}
});
}
}
}
关键点:
- 消息先落库再发送,保证消息不丢失
- 定时任务补偿,处理发送失败的消息
- 消费端幂等处理,避免重复消费
- 超过重试次数的消息转入死信队列,人工处理
四、服务间调用安全:怎么防止接口被乱调
4.1 问题背景
微服务内部互相调用时,如果不对调用方进行校验,任何一个服务被攻破,都可能直接调用其他服务的内部接口。
4.2 解决方案:内部认证服务
仓库中的 fangjia-auth-service 实现了服务间调用的认证机制:
java
@FeignClient(name = "auth-service")
public interface AuthClient {
@PostMapping("/auth/token")
AuthResponse getToken(@RequestBody AuthRequest request);
@PostMapping("/auth/verify")
boolean verifyToken(@RequestHeader("Authorization") String token);
}
调用流程:
- 服务 A 调用服务 B 前,先向认证服务申请 Token
- 服务 A 在请求头中携带 Token 调用服务 B
- 服务 B 通过拦截器验证 Token 的有效性
- 验证通过才执行业务逻辑
java
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Autowired
private AuthClient authClient;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String token = request.getHeader("Authorization");
if (StringUtils.isEmpty(token)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}
boolean valid = authClient.verifyToken(token);
if (!valid) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return false;
}
return true;
}
}
五、项目结构组织:怎么放代码才清晰
5.1 多模块项目结构
参考仓库的组织方式,一个完整的微服务项目可以这样分层:
bash
spring-cloud-project/
├── api-gateway/ # API 网关
├── service-common/ # 公共模块(工具类、常量、异常定义)
├── service-api/ # Feign 客户端定义(API SDK)
├── service-auth/ # 认证服务
├── service-user/ # 用户服务
├── service-order/ # 订单服务
├── service-message/ # 消息服务(可靠消息)
├── eureka-server/ # 注册中心
├── config-server/ # 配置中心
└── pom.xml # 父 POM,统一管理依赖版本
5.2 单个服务的内部结构
bash
service-user/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/example/user/
│ │ │ ├── UserApplication.java
│ │ │ ├── controller/ # 对外接口层
│ │ │ ├── service/ # 业务逻辑层
│ │ │ ├── mapper/ # 数据访问层
│ │ │ ├── entity/ # 实体类
│ │ │ ├── dto/ # 数据传输对象
│ │ │ ├── config/ # 配置类
│ │ │ └── feign/ # Feign 客户端(调用其他服务)
│ │ └── resources/
│ │ ├── application.yml
│ │ ├── application-dev.yml
│ │ └── mapper/ # MyBatis XML
│ └── test/
└── pom.xml
六、写在最后
微服务架构的落地不是一蹴而就的。从单体到微服务,需要考虑:
- 服务拆分是否合理 --- 拆得太细会增加调用复杂度,拆得太粗失去微服务的意义
- 数据库设计 --- 独立数据库是原则,但数据一致性成为新的挑战
- 分布式事务 --- 没有银弹,根据业务特点选择合适的方案
- 服务安全 --- 内部调用也需要认证,不能假设内网就是安全的
- 监控和治理 --- 服务多了之后,链路追踪、熔断降级、限流等能力必不可少
微服务不是目的,而是手段。不要为了拆分而拆分,根据团队规模和业务复杂度选择合适的架构方案,才是正确的做法。
- 第一版书籍:《Spring Cloud微服务-全栈技术与案例解析》
- 第二版书籍:《Spring Cloud微服务 入门 实战与进阶》