接手微服务项目多了之后,你会发现一个规律:大多数团队并不是被业务代码难倒的,而是倒在服务与服务的"连接"上。单体时代,模块之间直接函数调用,用户身份存在Session里,配置写在properties文件中,一切看起来都顺理成章。可一旦拆成微服务,请求路由、身份认证、配置管理这三件事就会同时浮出水面,谁躲都躲不掉。
这是微服务系列的第三篇,其实写到这里我自己感触挺深。前两篇讲的是服务拆分和通信,但真正让一个微服务架构"活起来"的,恰恰是这三件基础设施级别的能力。请求怎么走、身份怎么认、配置怎么管,如果这三件事没理清楚,拆出来的十几个服务只是十几个互相推诿的仓库而已。这篇文章我会把网关路由、统一认证、动态配置这三个模块串起来聊,不但给你能落地的配置和代码,还会把我在真实项目里踩过、调过、改过的那些坑一并写出来。适合正在做服务拆分、或者已经用Spring Cloud搭好骨架但总觉得别扭的同学参考。
1. 三件事本来就是一件事:先看懂一条完整请求
1.1 路由、认证、配置从来不是三条平行线
很多项目结构图上,网关、认证服务、配置中心被画成三个彼此独立的方框,团队也习惯分成三拨人维护。但实际上这三者是深度耦合的:网关在决定把请求转发给哪个服务之前,必须先看一眼这个请求有没有携带合法的身份凭证;服务在执行业务逻辑之前,要判断调用者有没有权限,而这个判断依据通常又是从配置中心读取的;就连网关自己的路由规则、限流阈值、白名单路径,本质上也都是配置数据。
如果把这些割裂开看待,系统最终会变成什么样子?网关成了一台哑巴转发器,任何请求都不加辨别地扔给下游;认证逻辑散落在每个服务里,每个服务维护一套密钥、一套解析逻辑,改一次密钥要通知所有团队;配置散落在每一台服务器的本地文件里,线上出了问题,你都不知道跑着的到底是哪一版配置。
所以我写这篇的时候,特意把三件事放在一起。不是标题排版的需要,而是它们在实际运行中就必须协作。
1.2 一条带身份信息的请求从入口到结束的完整路径
我们先在脑子里搭一条路:用户通过前端发起一个请求,请求头里带着登录时签发的JWT。请求到达网关,网关先做路由匹配——根据路径前缀决定该转发到哪个服务;紧接着做身份校验——验签、检查过期时间、查黑白名单。这一关过了,请求才被转发到真正的业务服务。
业务服务收到请求后,从Header里解析出当前用户信息,根据角色权限判断能不能执行这个操作。如果需要调用另一个服务,那就得通过内部调用的方式把身份信息继续传递下去。而整个过程里,服务连的数据库地址、缓存地址、各种开关阈值,都是从配置中心动态读取的;一旦配置变更,服务不需要重启就能拿到新值。
这条链路走下来你会发现:路由负责"让请求找到对的门",认证负责"证明进门的人是对的人",配置负责"让每一扇门知道自己该以什么状态运行"。三者相互依赖,少一环整个链路就走不通。
1.3 微服务拆分真正要拆的是"连接",不只是"业务"
我自己见过太多次"伪微服务改造"了。团队把用户、订单、支付几个模块拆成了独立服务,业务边界画得漂漂亮亮,架构评审时大家都点头。可一旦联调就出问题:服务间调用全靠手写HTTP Client,每个服务各自存一份加密密钥,网关只做了一层最简单的转发,配置改一次要登录好几台服务器手动替换文件再重启。
这种状态还不如不拆。微服务拆分表面上是把业务代码分到不同进程,本质上是把"连接方式"从函数调用改成了网络调用。网络是不稳定的,身份是容易丢失的,配置是多环境漂移的——这三件事不先治理好,拆得越碎,系统越脆。所以如果你正在规划微服务改造,我建议先把这篇讲的三件事落地,再动手拆业务,顺序别反。
2. 请求路由:从路由表达式到流量治理的落地细节
2.1 网关选型:为什么我最终选择了Spring Cloud Gateway
网关是微服务的流量入口,选型直接决定后面很多事情的走向。目前主流就三个:Zuul 1.x、Zuul 2.x、Spring Cloud Gateway。
Zuul 1.x基于Servlet阻塞模型,每个请求占用一个线程,网关这种高并发入口,一旦下游某个服务响应变慢,线程池很快被拖垮,这几乎是单体时代就有的老毛病。Zuul 2.x虽然改成了异步非阻塞,但推广一直不温不火,和Spring Cloud生态的集成程度也不如Gateway。Spring Cloud Gateway基于WebFlux和Netty实现,天然非阻塞,路由配置即改即用,而且和Nacos、Sentinel这些Alibaba系组件集成得非常顺滑。
近几年的微服务落地项目里,Spring Cloud Gateway基本成了默认选择。如果你的技术栈本身就是Spring Cloud,那Gateway就是成本最低、社区资料最充足的方案。当然,如果公司已经上了完整的服务网格,网关层的职责会被网格部分接管,但那是另一个话题了,咱们这里先按下不表。
2.2 一个能直接上线的网关路由配置长什么样
网关的核心概念其实就三个:Route(路由)、Predicate(断言)、Filter(过滤器)。用生活化的说法,路由就是一张快递分拣表,Predicate是分拣条件,Filter是包裹在分拣前后的额外处理——比如检查有没有违禁品、重新包装、贴标签。
下面是一个比较典型的Gateway配置,你直接照着改就能跑:
spring: application: name: gateway-service cloud: gateway: routes: - id: user-service-route uri: lb://user-service predicates: - Path=/api/user/** filters: - StripPrefix=2 - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 key-resolver: "#{@apiKeyResolver}" - id: order-service-route uri: lb://order-service predicates: - Path=/api/order/** filters: - StripPrefix=2这里最值得关注的是uri这行。lb://user-service不是随便写的,它告诉网关:去注册中心找到名为user-service的服务,然后用客户端负载均衡的方式选一个可用实例转发。如果你的网关只配了固定IP,例如uri: http://localhost:8081,那么下游服务扩容、缩容、漂移时,网关全都不感知,那路由也就失去了在微服务体系里的意义。
Predicate里常见的还有Method(限制请求方法)、Header(按请求头匹配)、Query(按参数匹配)等。例如:
predicates: - Path=/api/pay/** - Method=POST - Header=Content-Type, application/json这几条规则是"与"的关系,必须全部满足才会走这条路由。需要灵活组合规则时,还可以用X-Forwarded-Prefix等自定义断言,但说实话,80%的场景用Path加Method就够了,堆太多规则反而难维护。
2.3 路由命中顺序:一条被"吃掉"的路由规则
网关的路由规则是按配置顺序从上往下匹配的,第一条命中的规则生效,后面的直接跳过。这个特性如果不注意,很容易埋下一个隐蔽的雷。
我有一次排查一个诡异的问题:团队配置了/api/user/admin/**路由到user-admin-service,又配置了/api/user/**路由到user-service,但不管怎么访问/api/user/admin/info,请求总是跑到user-service去。管理员接口在user-service里根本没有实现,结果就是一片404。
看配置确实两条都在:
routes: - id: user-service-route uri: lb://user-service predicates: - Path=/api/user/** - id: user-admin-route uri: lb://user-admin-service predicates: - Path=/api/user/admin/**问题一目了然:user-service-route写在前面,/api/user/admin/info同样匹配/api/user/**,网关命中第一条后就直接转发了,后面那条更精确的规则永远没有机会执行。
解决方式有两种。一是把精确路由放在模糊路由前面,二是给精确规则加上更明确的断言。我个人的习惯是把特殊路径的路由统一放在通用路由之前,并且在命名上把优先级体现出来。这个顺序问题排查起来比较费时间,因为网关日志不会提示"有路由被遮蔽了",它只会默默地把请求送到错误的地方,你往往是在下游服务的日志里才看出端倪。
2.4 StripPrefix与路径重写:一次404排查实录
还有一个高频出问题的配置项:StripPrefix。它的作用是把请求路径的前N段剥掉再转发到下游服务。举个例子,客户端请求的是/api/user/info,配置StripPrefix=2后,网关会剥掉/api和/user两段,把/info转发到user-service。
想法是好的,但很多人对"剥掉几段"的理解有偏差。这里踩坑的点在于:StripPrefix=2是固定剥掉从左往右的两段,而不是剥掉"路径中某两个指定的词"。假设下游服务的接口定义是/user/info(Controller里带着/user前缀),而你在网关剥掉了两段,下游收到的只有/info,自然匹配不上路由,直接404。
我当时排查这个问题的过程大致是这样:先是看网关日志,确认请求确实转发到了user-service;再看user-service的访问日志,确认请求路径已经被改写成了/info;最后才对比Controller定义发现前缀不匹配。整个过程最费时间的是第一步——你根本没想到网关会把路径改写成这样。
如果不想用StripPrefix,也可以用RewritePath做更精细的路径重写。比如:
filters: - RewritePath=/api/user/(?<segment>.*), /$\{segment}这个意思更好理解:把/api/user/后面的部分原样保留,替换成新的路径。两种方式各有适用场景,StripPrefix适合路径结构规整的项目,RewritePath适合需要复杂映射的接口。但我建议在同一个项目里统一风格,不要一会儿用StripPrefix一会儿用RewritePath,不然排查起来绝对让人头大。
3. 身份认证:统一信任模型的建立与防坑实录
3.1 单体时代的Session方案,在微服务里为什么会翻车
单体时代,用户登录状态存在服务端Session里,SessionID放在Cookie里随着请求来回。这套机制在单个进程里很好使:用户来找我,我认一眼内存里的Session就能确认"你是谁"。但微服务一拆,问题就来了。
第一,服务有多个实例,用户的请求可能先打到实例A,再打到实例B,如果实例B的内存里没有这个Session,用户就"被登出"了。解决方案以前是做Session复制或粘滞会话,但在云原生环境下,实例随时可能重启、扩容,Session复制不仅浪费内存,而且在大规模节点下会产生很多网络开销。
第二,服务之间也要调用。订单服务需要知道当前用户ID才能查询订单列表,这个身份信息如果只存在于Session里,订单服务拿不到,只能再走一遍登录流程,这在微服务内部是没法接受的。
所以微服务时代的统一认证方案几乎都转向了Token——尤其是JWT。它的核心价值是自包含:用户信息直接编码在Token里,服务间传一个Token就能确认身份,不需要再回查Session存储。
3.2 JWT不是加密,是签名——先把这个概念理清
JWT的结构是一串由三个点分隔的字符串:Header.Payload.Signature。Header是算法信息,Payload是业务声明(用户ID、角色、过期时间),Signature是签名。很多人以为JWT里的信息是加密的,外部看不到,这是个危险的误解。
JWT的Payload只是Base64编码,任何人拿到Token都能解码看到内容。我曾经在一个项目里看到有人把数据库密码塞进JWT Payload里,这等于把密码印在纸上传遍整个系统,稍微懂点技术的人截获Token后Base64解码就全暴露了。JWT的签名只能保证"这段内容没有被篡改",不能保证"这段内容没有被读取"。敏感信息一律不能放进JWT的Payload,这是铁律。
落地时公钥私钥怎么分配也有讲究。我见到的合理方案是:网关持有公钥用于验签,认证服务持有私钥用于签发,下游服务也只持有公钥。这样即使某个服务被入侵,攻击者也签不出合法Token,最多验证已有Token是否有效,危害面被收窄了。如果实在只有一个密钥,那就要放在配置中心统一管理,而不是每个服务写死一份。
3.3 网关校验加服务自校验:一套我实践下来的双层模型
身份认证的落地方式,市面上有三种流派:只在网关校验、只在业务服务校验、网关加服务双层校验。
只在网关校验的问题是:网关验签通过后把请求转发到服务,但服务怎么确认请求是从网关来的?如果服务直接把接口暴露到公网,或者另一个服务绕过网关直接调用它,那网关的校验就形同虚设。只在服务校验的问题是:每个服务都要写一遍验签逻辑,重复代码爆炸,而且某一个服务的验签实现如果偷懒解析出错,系统整体行为就不可控。
我自己实践下来推荐的做法是:
- 网关层:负责宏观校验——验签、检查Token过期、维护黑名单、识别基础路径权限。
- 服务层:负责微观鉴权——从Token里解析出用户角色和权限码,和当前接口需要的权限做匹配。
网关层校验通过的请求,服务层也要验一次签名,但不是为了重复校验,而是为了防止绕过网关的非法调用。签名算法很快,性能损失可以忽略,多一层校验换来的安全性是值得的。
网关里做一个全局Filter来解析JWT,伪代码大概是这样的:
@Component public class JwtAuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // 放行白名单路径 // 取出 Header 中的 Authorization // 验签、校验过期 // 解析出 userId 和 roles,追加到请求头 // chain.filter(exchange) } }这里有一个细节容易被忽略:网关往下游服务传用户信息时,最好使用自定义的请求头,比如X-User-Id、X-User-Roles,而不是把原始的JWT再原样传下去。原因有两点:一是下游服务不一定要关心Token的完整结构,二是在某些场景下你希望在网关层对权限做裁剪(比如某些字段不让下游看见),自定义头可以更灵活地控制传递的信息范围。
3.4 服务间调用时Token丢失:Feign里的一个经典场景
微服务之间互相调用,身份信息怎么传递?很多人第一版代码里根本就没考虑这件事——服务A调用服务B的时候,直接发起一个新的HTTP请求,完全没有携带任何身份信息。服务B的网关层校验(如果有的话)直接拦截,因为请求头里没有Authorization。即使服务B没有做拦截,它也拿不到调用方的用户身份,业务逻辑就会出错。
我曾经遇到过这样一个场景:用户查询订单列表时,订单服务需要调用用户服务获取用户详情,但用户服务经过网关校验时发现请求头里没有Token,直接返回401。整个调用链是通的,但每个环节都缺了身份上下文。
解决办法是在Feign的调用链路上加上一个请求拦截器,把当前请求上下文中的Token透传下去:
@Configuration public class FeignAuthConfig { @Bean public RequestInterceptor authRequestInterceptor() { return requestTemplate -> { // 从当前请求上下文里取出原始 Token // requestTemplate.header("Authorization", token); }; } }这里又有一个坑:在非Web请求的线程中(比如异步任务、MQ消费者触发调用),当前请求上下文里没有Token,拦截器取出来是null,直接把一个null塞进Header,下游验签直接炸。所以拦截器里要做空值兜底,同时在设计上明确:服务间调用要么统一走内部调用凭证,要么强制要求必须有上游传入的用户上下文。最怕的就是"能不能取到完全看运气",这种不确定性在分布式系统里排查起来极其痛苦。
3.5 密钥分散管理的隐患:一次轮换密钥引发的集体事故
我参与过一个项目,每个服务自己存了一份JWT密钥,有写死在配置文件里的,有放在本地yml里的。某次安全审计要求轮换密钥,团队建了一个微信群,通知所有服务负责人"今天下班前把密钥改成新值"。
结果呢?有的服务改了,有的服务没改。没改的服务继续用旧密钥签发和验证,改了密钥的服务验证新Token时,发现签名对不上,全部401。最惨的是认证服务自己,签发的Token在网关验签时直接被拒,用户全部被登出。整个线上系统乱成一锅粥。
这件事的教训是:所有服务用于验签的密钥必须统一从配置中心读取,而不是散落在每个服务的本地文件里。配置中心保证了一致性和可控性——要轮换,在配置中心改一次,所有服务动态感知。这种"一处修改、全局生效"的能力,正是配置管理模块存在的核心价值。于是话题自然过渡到下一节:配置中心。
4. 配置管理:配置中心的选型、接入与动态刷新
4.1 微服务化之后,配置问题为什么被无限放大
单体时代改一个数据库连接池大小,改一个properties文件,重启一次就完事。微服务把这个问题放大了多少倍?我给你算一笔账:假设你有10个服务,每个服务3套环境(开发、测试、生产),每个服务在每套环境下平均2个配置项需要区分。那一次数据库地址变更,你要改的地方是10乘3等于30处。如果再算上服务多实例部署,配置管理不当的话,登录服务器逐个替换的成本简直不可想象。
更难受的是"配置漂移"。所谓漂移,就是你以为所有服务都用的同一套配置,但因为某台服务器的手工改动没有记录、某个实例还是旧版本没上线,结果线上环境里每个实例跑的配置各不相同。出了问题你查A服务是对的,查B服务也看不出问题,但A和B的行为就是不一样。这时候你根本没有一个"标准答案"可以参考,因为标准答案在你这里已经不存在了。
4.2 主流配置中心选型:Nacos、Apollo、Spring Cloud Config怎么选
市面上的配置中心选择不少,我按实际使用感受整理了一张对比表:
| 维度 | Nacos | Apollo | Spring Cloud Config |
|---|---|---|---|
| 动态刷新 | 原生支持,无需额外组件 | 原生支持,粒度细 | 需配合Bus消息总线 |
| 部署复杂度 | 低,单机也能跑 | 中,依赖Eureka和数据库 | 中,需要独立配置仓库 |
| 权限与审计 | 基础权限控制 | 强,支持灰度发布和审计 | 弱,没有内置控制台 |
| 配置版本管理 | 支持回滚 | 支持回滚和发布记录 | 依赖Git仓库历史 |
| Spring生态集成 | 好,Spring Cloud Alibaba | 一般,需额外适配 | 最好,毕竟是Spring官方 |
中小团队我比较推荐Nacos。理由很直接:它不仅仅是配置中心,还是注册中心,一套组件同时解决服务发现和配置管理,部署起来轻,也不需要额外引入消息总线。团队如果已经有Spring Cloud Alibaba的底子,Nacos基本上是顺理成章的选择。
团队规模大、配置审计要求高的场景,Apollo会更合适。它的权限管理做得好,可以精确到某个配置项谁改过、什么时候改的、改之前是什么值,这类能力在大团队里非常受用。Spring Cloud Config我个人的评价是"能跑但不好用",它更像一个把Git配置翻译成外部化配置的工具,动态刷新还得搭Spring Cloud Bus,链路长了稳定性和排查成本都会增加。
4.3 用Nacos管理配置的核心模型:Namespace、Group、DataId
Nacos的配置管理有三个核心概念:Namespace(命名空间)、Group(分组)、DataId(配置ID)。很多初学者会在这里搞混,我用一套完整的例子把它讲明白。
- Namespace:用于隔离环境。我通常一个环境一个Namespace,比如
dev、test、prod。同一个服务在不同环境中配置内容不同,Namespace就是它们的"隔离带"。 - Group:用于区分业务域。默认用
DEFAULT_GROUP就行,但我建议按服务域甚至按服务名来分,方便权限控制。 - DataId:配置文件的唯一标识。Nacos里DataId的命名一般和Spring的配置文件对应,例如
user-service.yaml。
在Spring Boot中接入Nacos配置,重点是bootstrap.yml里的配置:
spring: application: name: user-service cloud: nacos: config: server-addr: ${NACOS_SERVER_ADDR} file-extension: yaml namespace: ${NACOS_NAMESPACE} group: DEFAULT_GROUP这里有一个我踩过很多次的坑:namespace填的是Nacos控制台里生成的ID(一串UUID),不是环境名字。很多人图省事直接填dev,服务启动后读不到配置中心的配置,日志里也没有任何报错,然后就开始怀疑人生。Nacos控制台里每个Namespace都有一串唯一的ID,要复制那个值而不是名字,这个细节至少坑过我和我身边的三个同事。
4.4 长轮询刷新机制与@RefreshScope:动态刷新不是玄学
Nacos的动态刷新原理并不神秘,简单说就是客户端向服务端发起一个长轮询请求,服务端把这个请求Hold住30秒,如果期间配置有变化就立即返回响应;没变化就等30秒超时后再建立下一次轮询。客户端收到变化通知后,把新配置拉下来,触发Spring容器的刷新。
在Spring生态里,实现动态刷新的关键注解是@RefreshScope。被这个注解标注的Bean会在配置刷新后被重新创建,从而拿到新配置。一个典型的用法:
@Component @RefreshScope public class AppConfigHolder { @Value("${order.timeout-seconds}") private int timeoutSeconds; public int getTimeoutSeconds() { return timeoutSeconds; } }配置中心的order.timeout-seconds改了之后,这个Bean会自动重建,新值随之生效。没有@RefreshScope的话,即使是@Value注入的字段也不会自动变化,这一点很多人测试的时候才意识到。
4.5 动态刷新不是万能药:连接池不会自己重建
@RefreshScope并不会解决所有配置变更的"后处理"问题。最典型的例子是数据库连接池。假设你更新了数据库地址,Spring容器刷新了Bean,但是连接池中的旧连接并没有自动关闭,它们仍然指着老地址。新请求可能会使用新地址创建连接,但已存在的连接池如果管理不当,就会出现"一半连接指向A库、一半连接指向B库"的诡异状态,严重的会直接写入错误的数据。
这种情况的解决办法是监听配置刷新事件,在连接池的配置Bean被刷新后主动关闭旧连接池或者触发重建。Nacos提供了配置变更监听器,可以注册一个回调:
@Component public class DataSourceConfigListener { @EventListener public void onConfigChange(RefreshEvent event) { // 清理连接池、重建数据源 } }这块的逻辑要根据你的连接池实现(HikariCP、Druid等)来定制,没有统一答案,但一定要在架构设计时留出这个钩子。很多团队接入配置中心后觉得"改了就能动",真到线上改数据库地址时才发现服务还能跑但连接全乱了,这种事故在凌晨的告警群里屡见不鲜。
4.6 配置中心宕机时,服务还能不能启动
还有一个值得提前设计的问题:配置中心挂了,服务能不能正常启动?答案取决于你是否配置了本地兜底缓存。
Nacos客户端的默认行为是启动时从配置中心拉取配置,如果拉取失败会尝试使用本地的快照缓存(如果之前成功拉取过)。所以我的建议是:服务首次启动前,先把配置中心的配置人工核对一遍,确保本地缓存存在且正确;同时把服务器配置文件的优先级处理好,避免本地配置意外覆盖了配置中心的配置。
我在一个项目里遇到过这样的情况:某次运维在服务器上改了本地的application.yml,添加了一个调试开关,后来忘了删。这个调试开关在配置中心里没有对应项,本地文件优先级高于配置中心,于是这个服务在很长一段时间里一直以调试模式运行,日志里莫名多出一堆调试输出,排查起来完全没有头绪。所以配置中心接入之后,一定要和团队约定好:服务器本地配置文件原则上不再手动修改,一切配置变更走配置中心。这是纪律问题,不是技术问题。
5. 一次真实链路复盘:从登录到服务间调用的完整旅程与故障复盘
5.1 场景设定:一个订单查询请求怎样穿过所有关卡
为了把前面三块内容串起来,我用一个具体场景完整走一遍:用户在App上查询自己的订单列表。
用户登录时,认证服务校验用户名密码,签发一个JWT,里面包含userId和角色信息,返回给前端。前端每次请求都在Header中携带Authorization: Bearer <JWT>。
请求到达网关,网关的路由规则匹配到/api/order/**,交到order-service。在转发之前,JWT全局过滤器先验签、检查过期时间,然后把X-User-Id和X-User-Roles这两个自定义Header追加到请求上,再转发给订单服务。订单服务不需要关心JWT怎么解析,直接读X-User-Id这个Header就能知道是谁在查订单。与此同时,订单服务从配置中心读取数据库地址、订单状态映射表、缓存开关等一系列配置,执行查询逻辑。
查询过程中,订单服务需要调用用户服务获取用户的会员等级。这时候Feign拦截器起作用了:它从当前请求上下文中取出原始Token,添加到内部调用请求头里。用户服务收到请求后,验证签名,读取X-User-Id确认调用者身份,然后返回会员信息和积分。整个链路跑完,用户看到订单列表,一切正常。
5.2 故障1:配置中心更新了,但连接池还攥着旧地址不放
我在这个链路里真实遇到过的问题:某次生产环境的数据库需要迁移到新机器,运维在配置中心把spring.datasource.url改成了新地址,配置中心里显示所有服务都已同步,日志中也能看到刷新动作。但随后业务方报障——新写入的订单数据查不到了。
排查到最后发现,订单服务的数据源Bean虽然被@RefreshScope重建了,但HikariCP的连接池在重建时没有完全释放旧的连接,部分请求走了旧连接,指向已经迁移走的旧数据库地址。这个问题在测试环境很难复现因为数据量小、连接池连接少,一上生产就露馅。
后来我的处理方式是在配置刷新事件里主动关闭旧数据源,让连接池强制重建,并且加了监控指标:连接池的活跃连接数、当前数据源地址和配置中心期望值做一致性比对,一旦发现不一致立即告警。配置变更不只是"改个值",它还可能引发资源层面的连锁反应,这是动态配置最容易忽略的地方。
5.3 故障2:Token存进了localStorage,一场XSS引发的"信任"崩塌
另一个和身份认证强相关的真实事故:团队前端为了方便,把JWT放在localStorage里,每次请求取出来放到Header。这种方式省事,但是只要页面里任何一个脚本被注入了恶意代码(XSS攻击),攻击者就能直接读取localStorage拿到Token,然后以用户身份调用任何接口。
那次事故的具体形态是:第三方统计脚本里被植入了恶意代码,窃取了若干管理员的JWT,然后在凌晨用管理员账号调了一批敏感接口。事后复盘时,除了修复XSS漏洞和回收原有Token,我们还做了三件事:第一,把Token存放位置从localStorage改到HttpOnly Cookie,这样JS脚本无法读取;第二,给JWT加上了刷新机制而不是一个超长过期时间,缩短Token暴露后的有效窗口;第三,对异常行为(比如凌晨批量调用敏感接口)增加告警。身份认证从来不是后端单方面的事,前端的存储和传递方式同样决定了整个信任模型是否安全。
5.4 故障3:网关限流和业务限流两套规则互相打架
还有一个在路由和配置交叉处踩过的坑。网关层为了防刷,配置了RequestRateLimiter限流,每秒放行10个请求;而业务服务自己也接了一套限流组件,每秒放行20个。表面上两套限流都开着,但线上压测时用户响应大量超时,排查下来发现:网关限流10个通过后,业务服务处理能力其实是够的,但业务服务的限流组件在同一时刻因为某种边缘情况触发,拒绝了本来已经通过网关的请求;两边阈值和算法不一致,导致整体行为不可预测。
这个问题的本质其实是"两套配置没有统一治理"。后来我们把限流配置全部收口到网关层,业务层只保留最基础的兜底限流(阈值设得明显高于网关),并且在配置中心里统一管理所有服务的限流参数,每个环境一套值。整个链路的流量行为这才变得可预期。微服务里的很多问题,并不是某条规则错了,而是多条规则互相之间没有对齐。
6. 落地节奏、验收清单和几条心血总结
6.1 正确的落地顺序:先配置中心,再统一认证,最后才是网关路由
文章最后这部分,我把自己做这类基础设施的经验按顺序列一下,希望对准备动手的同学有参考价值。
第一步:先上配置中心。理由很简单,路都要先铺好,所有的开关、密钥、地址都必须有统一的管理入口。这一步解决的是"每个服务各自为政"的乱象。
第二步:建立统一认证。把JWT签发、验签、密钥管理形成一个独立的能力,所有服务接同一套信任模型。这一步解决的是"服务间不知道你是谁"的问题。
第三步:配网关路由。在统一认证的基础上,网关做路由和验签才谈得上有意义。如果这两件事顺序反了,网关先配好了,结果下游服务各自的认证体系还没统一,网关验签通过了,下游服务还会拒绝,整条链路依然不通。
6.2 一个小型团队可以直接抄的验收清单
- 配置中心已接入所有服务,本地不再手工修改关键配置项;
- 所有服务启动后能从配置中心拉到正确环境配置,且本地有缓存兜底;
- JWT密钥统一由配置中心管理,轮换密钥一次生效,无服务需要手动改文件;
- 网关所有路由规则按精确度排序,已确认无遮蔽问题;
- 服务间Feign调用能透传身份上下文,且有空值兜底;
- 每次配置变更后,观察连接池、线程池等资源是否真正重建,而不是只看日志里"刷新成功"四个字。
6.3 最后几句掏心窝的话
把路由、认证、配置这三块交给一个团队去治理,而不是让每个微服务各自搞一套,是我在多个项目里换来的最深刻教训。技术上它们各有各的实现细节,但真正让系统稳定运行的,其实是"统一"两个字:身份模型要统一,配置入口要统一,流量入口要统一。一个微服务系统如果每个环节都在重复制造轮子,那么规模越大,熵增越快,最终大家都会陷在"改了一个服务,另外五个服务跟着出问题"的泥潭里。
这套东西做扎实之后,你会发现后续拆新服务、接新团队都变得特别快:新服务只要接入配置中心、接上统一认证、在网关加一条路由规则,就能立刻融入整个体系。基础设施的价值不在上线那一刻,而在你每一次变更和扩容的时候。希望这篇对正在做微服务基础治理的同学有实际帮助。如果你们团队在这三块里有更有意思的取舍和踩坑经历,也欢迎在评论区聊一聊——微服务这条路,一个人走太孤单了。