SaaS 企业级分布式微服务架构实战:Nacos 服务治理与多租户体系设计
·
从零构建一套生产可用的 SaaS 微服务底座——服务注册发现、RPC 通信、多租户隔离、统一鉴权,全流程代码级拆解。
一、为什么 SaaS 场景需要一套微服务底座?
单体时代,租户隔离靠"字段区分"就行;但进入 SaaS 模式后,业务复杂度飙升:
- 租户级配置不同:A 租户要微信登录,B 租户要钉钉登录
- 流量不均衡:头部租户 QPS 过万,尾部租户长尾空闲
- 独立演进诉求:不同模块迭代节奏不同,必须拆服务
选型:
| 能力 | 选型 | 理由 |
|---|---|---|
| 注册中心/配置中心 | Nacos 2.x | 支持 gRPC 长连接,性能远超 HTTP 短轮询 |
| RPC 通信 | OpenFeign + Dubbo 双协议 | 对外 Feign RESTful,内部 Dubbo 高性能 |
| 网关 | Spring Cloud Gateway | 响应式 + 过滤器链 |
| 鉴权 | Sa-Token + 自定义注解 | 轻量且支持多租户上下文透传 |
| 多租户 | 行级隔离(共享表 + tenant_id) | 成本可控,查询效率高 |
二、Nacos 服务注册与发现——从配置到实战
2.1 引入依赖
<!-- Nacos 服务注册发现 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<version>2022.0.0.0</version>
</dependency>
<!-- Nacos 配置中心 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
<version>2022.0.0.0</version>
</dependency>
2.2 bootstrap.yml——服务注册的核心配置
spring:
application:
name: ecf-system # 服务名,注册到 Nacos 的唯一标识
cloud:
nacos:
discovery:
server-addr: 192.168.1.100:8848 # Nacos 集群地址
namespace: ecf-prod # 命名空间隔离环境
group: ECF_GROUP # 分组隔离业务域
cluster-name: BJ # 集群名,同机房优先调用
weight: 1 # 实例权重,影响负载均衡
metadata: # 自定义元数据,灰度路由用
version: v2
region: beijing
config:
server-addr: 192.168.1.100:8848
namespace: ecf-prod
group: ECF_GROUP
file-extension: yml # 配置文件格式
shared-configs: # 共享配置,多服务复用
- data-id: ecf-common-redis.yml
group: ECF_GROUP
refresh: true # 支持动态刷新
生产经验:cluster-name 一定要配!Nacos 在同集群内优先路由,跨集群走容灾,延迟差异可达 10 倍。
2.3 启动类——启用服务注册
@EnableDiscoveryClient // 开启服务注册发现
@SpringBootApplication
public class EcfSystemApplication {
public static void main(String[] args) {
SpringApplication.run(EcfSystemApplication.class, args);
}
}
2.4 Nacos 2.x 的 gRPC 通信——你必须知道的端口
Nacos 2.x 除了主端口 8848,还额外开放了 gRPC 端口:
9848:客户端 gRPC 通信端口(主端口 + 1000)9849:服务端集群间 gRPC 通信端口(主端口 + 1001)
踩坑实录:防火墙只开了 8848,服务注册成功但心跳上报失败,30 秒后实例被标记为不健康。务必开放 9848/9849。
三、RPC 微服务调用——OpenFeign + Dubbo 双协议实战
3.1 OpenFeign 声明式调用(对外 REST 接口)
定义远程调用接口:
/**
* 系统用户远程调用客户端
* name: 目标服务在 Nacos 中的注册名
* path: 统一API前缀
*/
@FeignClient(
name = "ecf-system",
path = "/system",
fallbackFactory = UserClientFallbackFactory.class // 降级工厂
)
public interface UserClient {
/**
* 根据租户ID查询用户列表
* 多租户场景下,tenantId 通过拦截器自动注入 Header
*/
@GetMapping("/user/listByTenant")
Result<List<UserVO>> listByTenant(@RequestParam("tenantId") Long tenantId);
/**
* 根据用户名查询用户详情(鉴权网关调用)
*/
@GetMapping("/user/getInfo")
Result<UserVO> getInfo(@RequestParam("username") String username);
}
降级工厂——服务不可用时的兜底:
@Component
@Slf4j
public class UserClientFallbackFactory implements FallbackFactory<UserClient> {
@Override
public UserClient create(Throwable cause) {
log.error("ecf-system 服务调用失败,触发降级", cause);
return new UserClient() {
@Override
public Result<List<UserVO>> listByTenant(Long tenantId) {
return Result.fail("用户服务暂时不可用,请稍后重试");
}
@Override
public Result<UserVO> getInfo(String username) {
return Result.fail("用户服务暂时不可用,请稍后重试");
}
};
}
}
3.2 Dubbo 高性能内部调用(服务间高频通信)
当内部服务间调用量大(如订单 → 商品 → 库存链路),OpenFeign 的 HTTP 开销成为瓶颈,切换 Dubbo 协议:
/**
* Dubbo 服务提供方——商品服务接口定义(API 模块)
*/
public interface ProductDubboService {
/**
* 批量查询商品库存
* @param productIds 商品ID列表
* @return 商品库存Map,key=商品ID,value=库存数量
*/
Map<Long, Integer> batchQueryStock(List<Long> productIds);
/**
* 扣减库存(幂等操作,基于订单号去重)
* @param orderId 订单号(幂等键)
* @param items 扣减明细
* @return 是否扣减成功
*/
boolean deductStock(String orderId, List<StockDeductDTO> items);
}
/**
* Dubbo 服务提供方实现
* 使用 @DubboService 注册到 Nacos
*/
@DubboService(
version = "1.0.0",
group = "ECF_GROUP",
timeout = 3000,
retries = 2, // 重试2次(读操作可重试,写操作慎用)
loadbalance = "roundrobin" // 负载均衡策略
)
@Slf4j
public class ProductDubboServiceImpl implements ProductDubboService {
@Autowired
private ProductStockMapper stockMapper;
@Override
public Map<Long, Integer> batchQueryStock(List<Long> productIds) {
log.info("批量查询库存,商品数量: {}", productIds.size());
List<ProductStock> stocks = stockMapper.selectBatchIds(productIds);
return stocks.stream()
.collect(Collectors.toMap(
ProductStock::getProductId,
ProductStock::getAvailableQty
));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deductStock(String orderId, List<StockDeductDTO> items) {
log.info("扣减库存,订单号: {}, 明细数: {}", orderId, items.size());
// 幂等校验:已处理的订单直接返回成功
if (stockMapper.existsDeductLog(orderId)) {
log.warn("重复扣减请求,订单号: {}", orderId);
return true;
}
// 逐项扣减(乐观锁防止超卖)
for (StockDeductDTO item : items) {
int affected = stockMapper.deductWithCas(
item.getProductId(), item.getQty(), item.getExpectVersion()
);
if (affected == 0) {
throw new StockDeductException("库存扣减失败,商品ID: " + item.getProductId());
}
}
// 记录扣减日志(幂等键)
stockMapper.insertDeductLog(orderId, items);
return true;
}
}
消费方调用:
@DubboReference(
version = "1.0.0",
group = "ECF_GROUP",
timeout = 3000,
check = false // 启动时不检查,避免循环依赖卡死
)
private ProductDubboService productDubboService;
3.3 双协议切换——一套接口两种实现
# application-dubbo.yml——切换内部调用为 Dubbo
dubbo:
protocol:
name: dubbo
port: 20881 # -1 表示随机端口
registry:
address: nacos://192.168.1.100:8848 # 注册到 Nacos
parameters:
namespace: ecf-prod
group: ECF_GROUP
consumer:
check: false # 启动时不检查提供者
timeout: 3000
四、多租户体系设计——租户上下文透传
4.1 租户上下文工具类
/**
* 多租户上下文持有者
* 基于 ThreadLocal 在当前线程中传递租户信息
* 网关鉴权后,将 tenantId 写入请求 Header,微服务通过拦截器提取
*/
public class TenantContext {
private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>();
/** 设置当前租户ID */
public static void setTenantId(Long tenantId) {
TENANT_HOLDER.set(tenantId);
}
/** 获取当前租户ID */
public static Long getTenantId() {
Long tenantId = TENANT_HOLDER.get();
if (tenantId == null) {
throw new TenantException("租户上下文丢失,请检查拦截器配置");
}
return tenantId;
}
/** 清除上下文(必须在请求结束后调用,防止线程池污染) */
public static void clear() {
TENANT_HOLDER.remove();
}
}
4.2 Feign 拦截器——自动透传租户ID
/**
* Feign 请求拦截器
* 在每次远程调用时,自动将当前租户ID注入到请求Header中
* 保证微服务链路上的租户上下文不丢失
*/
@Component
public class TenantFeignInterceptor implements RequestInterceptor {
private static final String TENANT_HEADER = "X-Tenant-Id";
@Override
public void apply(RequestTemplate template) {
Long tenantId = TenantContext.getTenantId();
if (tenantId != null) {
template.header(TENANT_HEADER, String.valueOf(tenantId));
}
}
}
4.3 MyBatis-Plus 行级租户隔离
/**
* 多租户SQL拦截器
* 自动在 SELECT/UPDATE/DELETE 语句中拼接 tenant_id 条件
* 无需每个 Mapper 手动写 tenant_id 过滤
*/
@Component
public class EcfTenantLineHandler implements TenantLineHandler {
/** 忽略租户隔离的表(如:租户表本身、公共字典表) */
private static final Set<String> IGNORE_TABLES = Set.of(
"sys_tenant", "sys_dict_type", "sys_dict_data", "sys_config"
);
@Override
public Expression getTenantId() {
// 从上下文获取当前租户ID
Long tenantId = TenantContext.getTenantId();
return new LongValue(tenantId);
}
@Override
public boolean ignoreTable(String tableName) {
return IGNORE_TABLES.contains(tableName);
}
}
/**
* MyBatis-Plus 配置类——注册租户拦截器
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(
EcfTenantLineHandler tenantLineHandler) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 租户拦截器(必须在分页拦截器之前,否则分页SQL会漏掉租户条件)
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(tenantLineHandler));
// 分页拦截器
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
五、统一网关鉴权——租户路由 + Token 校验
/**
* 网关全局过滤器——租户上下文注入 + Token校验
* 请求链路:客户端 → Gateway → 微服务
* 网关负责:1. 校验Token 2. 解析租户ID 3. 注入Header透传
*/
@Component
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private SaTokenService saTokenService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 白名单路径直接放行(登录、注册、验证码等)
if (isWhitelistPath(path)) {
return chain.filter(exchange);
}
// 从Header中获取Token
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StrUtil.isBlank(token)) {
return unauthorized(exchange, "缺少认证Token");
}
// Token 校验
Object loginId = saTokenService.getLoginIdByToken(token);
if (loginId == null) {
return unauthorized(exchange, "Token已过期或无效");
}
// 解析租户ID(Token中携带)
Long tenantId = saTokenService.getTenantId(loginId);
if (tenantId == null) {
return unauthorized(exchange, "租户信息缺失");
}
// 将租户ID和用户ID注入下游请求Header
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-Tenant-Id", String.valueOf(tenantId))
.header("X-User-Id", String.valueOf(loginId))
.build();
log.debug("网关鉴权通过,租户: {}, 用户: {}, 路径: {}", tenantId, loginId, path);
return chain.filter(exchange.mutate().request(request).build());
}
@Override
public int getOrder() {
return -100; // 优先级最高,确保所有请求都经过鉴权
}
/** 返回401未授权响应 */
private Mono<Void> unauthorized(ServerWebExchange exchange, String msg) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = JSONUtil.toJsonStr(Result.fail(401, msg));
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(body.getBytes());
return exchange.getResponse().writeWith(Mono.just(buffer));
}
}
六、生产级部署要点
| 要点 | 实践 | 说明 |
|---|---|---|
| Nacos 集群 | 3 节点 + MySQL 持久化 | 内置 Derby 不支持集群,必须切 MySQL |
| 服务优雅下线 | spring.cloud.nacos.discovery.enabled=false + 等待 30s | 先从注册中心摘除,等存量请求处理完再停进程 |
| 配置灰度 | Nacos 配置 version=v2 + 网关路由规则 | 新版本实例打标签,灰度流量只路由到标签实例 |
| Dubbo 超时 | 写操作 retries=0,读操作 retries=2 | 写操作重试会引发重复扣减,必须幂等或禁重试 |
| 租户隔离边界 | 行级隔离 + 数据库Schema隔离备选 | 超大租户可单独拆 Schema,中小租户共享表行隔离 |
七、架构全景图
┌─────────────────────┐
│ Nginx / SLB │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Gateway (鉴权+路由) │
│ X-Tenant-Id 注入 │
└──────────┬──────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌────────▼───────┐ ┌─────────▼──────┐
│ ecf-system │ │ ecf-product │ │ ecf-order │
│ (用户/租户) │ │ (商品/库存) │ │ (订单) │
└───────┬───────┘ └────────┬───────┘ └─────────┬──────┘
│ Feign/Dubbo │ Dubbo │ Feign
└───────────────────┴─────────────────────┘
│
┌──────────▼──────────┐
│ Nacos 2.x 集群 │
│ 注册中心 + 配置中心 │
└─────────────────────┘
总结
本文从 Nacos 服务注册发现出发,完整拆解了 SaaS 企业级分布式微服务的核心架构:OpenFeign 对外 REST + Dubbo 内部高性能双协议通信、多租户行级隔离与上下文自动透传、网关统一鉴权注入租户标识。这套方案已经在我们的生产环境稳定运行一年以上,日均调用量千万级,租户隔离零穿透。
核心原则:不要为了微服务而微服务——先明确业务边界,再用 Nacos 做好服务治理,让每一行拆分都有收益。
更多推荐

所有评论(0)