从零构建一套生产可用的 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 做好服务治理,让每一行拆分都有收益。


更多推荐