本页定位 · Spring 进阶

《Spring 核心》《Spring Boot》《Spring Cloud》讲了 IoC、AOP、自动配置、微服务。本页补企业级后端绕不开的硬骨头:容器与 Bean 生命周期、AOP 代理底层、安全认证授权、OAuth2 单点登录、缓存抽象与事务传播、测试体系、接口文档与可观测,以及 Spring Cloud 的微服务治理。我会像《Java 进阶》那样,把每个点拆成"是什么 → 为什么 → 怎么用 → 踩过什么坑"。基线:Spring Boot 3.x(jakarta 命名空间)/ Spring Security 6 / Spring Cloud 2023+。

1

IoC/DI 容器与 Bean 生命周期、作用域

Inversion of Control · Dependency Injection · Bean Lifecycle

很多人用 Spring 多年,仍说不清"容器到底替我干了什么"。这一章把对象的创建、装配、存活范围、销毁彻底讲透——这是后面所有注解(@Cacheable/@Async/@Transactional)能生效的地基。

IoC 反转的是什么:控制权交给容器

没有容器时,对象自己 new 依赖、自己管生命周期;用了 Spring,你只声明"我需要什么",容器负责把它造好、组装好、按时给你。这就是"控制反转"。好处不是少写一行 new,而是依赖关系集中、可替换、可测试。

对比自己 new交给容器(DI)
依赖来源类里硬编码 new Repo()构造函数声明,容器注入
替换实现改源码换一个 @Bean 即可
单测难 mock,强耦合注入 mock 对象即可测
生命周期自己管容器统一管(作用域/销毁)

论为什么 Spring 推荐构造器注入

字段注入(@Autowired 直接标属性)写起来爽,但对象在构造完仍是"半残"(依赖为 null 直到容器回填),单测很难不靠容器。构造器注入让依赖在 new 时就齐活、不可变、强制非空,设计上更诚实。

容器启动:从配置到 BeanDefinition

容器启动做三件事:① 读配置(@Configuration / 组件扫描 @ComponentScan 得到一堆候选类);② 解析成 BeanDefinition(记录类、作用域、依赖、初始化方法等元信息);③ 按需实例化并走生命周期。理解"先有定义、后有实例",就知道为什么循环依赖有那么多讲究。

// 用 @Configuration 显式声明 Bean:方法返回的对象交给容器管理 @Configuration public class AppConfig { @Bean // 方法名就是 bean 名 "userRepo" public UserRepo userRepo(DataSource ds) { return new JdbcUserRepo(ds); // 参数 ds 由容器自动注入 } }

依赖注入的三种方式

// 1) 构造器注入:推荐,依赖不可变、必填 @Service public class UserService { private final UserRepo repo; public UserService(UserRepo repo) { this.repo = repo; } } // 2) Setter 注入:可选/可变的依赖,配合 @Autowired(required=false) @Autowired public void setCache(Cache c) { this.cache = c; } // 3) 字段注入:写法最短但最不推荐(见上一节"半残"问题) @Autowired private AuditLog audit;

Bean 作用域:同一个类能有几个实例

默认是 singleton(全容器一个实例,线程共享),但有的对象必须每次新建(如带状态的请求上下文)。作用域选错是隐蔽 bug 的高发区。

作用域含义典型用途
singleton(默认)整个容器一个实例无状态 Service / DAO
prototype每次获取 new 一个有内部状态、非线程安全对象
request每个 HTTP 请求一个请求级上下文(Web 环境)
session每个会话一个登录用户状态
application每个 ServletContext 一个全局共享配置
singleton 注入 prototype 的坑

singleton Bean 在容器启动时一次性创建,此时注入的 prototype 也被"固定"了——你以为每次拿到新的,其实拿到的是同一个。要每次拿新实例,得用 ObjectProvider 或 @Lookup 让容器在运行时再给。

生命周期回调:对象生老病死都有钩子

// 初始化与销毁钩子:@PostConstruct 在依赖注入后执行,@PreDestroy 在容器关闭前执行 @Component public class PooledClient { @PostConstruct public void init() { connect(); } // 启动连接、预热缓存 @PreDestroy public void destroy() { close(); } // 释放连接,避免泄漏 } // 更重的扩展点:BeanPostProcessor——每个 Bean 初始化前后都会被它"过一遍" @Component public class AuditPostProcessor implements BeanPostProcessor { public Object postProcessAfterInitialization(Object bean, String name) { if (bean instanceof Secured) log.info("bean ready: {}", name); return bean; } }

论初始化顺序:构造 → 依赖注入 → @PostConstruct → 可用

记住这个顺序能避开很多"空指针":构造器里不能用还没注入的依赖;需要用到依赖做准备的活,放进 @PostConstruct。AOP 代理也在这里被织入——所以 @PostConstruct 里看到的 bean 可能已是代理对象。

循环依赖与三级缓存

A 依赖 B、B 又依赖 A,怎么办?Spring 用三级缓存解决 singleton 的 setter/字段循环依赖:提前暴露"半成品"对象引用,让对方先拿到。但构造器注入的循环依赖无解——两边都在等对方构造完才能注入,直接启动报错。

// 构造器循环依赖:启动会抛 BeanCurrentlyInCreationException @Service public class A { public A(B b) {} } @Service public class B { public B(A a) {} } // 谁先? 谁都先不了 // 解法:改用 @Lazy 或字段/Setter 注入,或重构把公共逻辑抽成第三方 @Service public class A { public A(@Lazy B b) {} }

@Conditional 与自动配置原理

Spring Boot 的"开箱即用"靠的就是条件化装配:@ConditionalOnClass(类路径有某类才配)、@ConditionalOnMissingBean(用户没自定义才用默认)。理解它就懂了为什么引入一个 starter 自动生效、又能被你轻易覆盖。

// 自动配置套路:用户没提供 DataSource 时才给一个内存库 @Configuration @ConditionalOnClass(HikariDataSource.class) @ConditionalOnMissingBean(DataSource.class) public class DataSourceAutoConfig { @Bean DataSource dataSource() { return new HikariDataSource(); } }
2

AOP 原理(代理/JDK vs CGLIB)与实战

Aspect · Proxy · JDK Dynamic · CGLIB

日志、事务、鉴权……这些"横切"在多个方法上的逻辑,不该复制粘贴到业务里。AOP(面向切面编程)用代理把横切逻辑织入。这一章先讲底层代理,再写三个真正能上生产的切面。

AOP 是什么:把"横切关注点"拎出来

业务方法只关心"算账",不关心"记日志、开事务、鉴权"。AOP 把这些通用能力抽成切面(Aspect),在指定切点(Pointcut)处织入。织入的时机叫"通知(Advice)"。

通知类型执行时机典型用途
@Before目标方法前参数校验、鉴权
@After方法后(无论成败)资源清理
@AfterReturning正常返回后结果加工、审计
@AfterThrowing抛异常后异常告警
@Around包围整个调用计时、事务、重试(最灵活)

代理机制:JDK 动态代理 vs CGLIB

Spring AOP 默认用代理实现。被代理对象有接口时用 JDK 动态代理(基于接口),没接口时用 CGLIB(基于子类继承)。Spring Boot 2.0+ 默认统一用 CGLIB,更省心但代价是类必须能被继承(final 类/方法无法代理)。

// 接口 + 实现:走 JDK 动态代理 public interface OrderService { void place(Order o); } @Service public class OrderServiceImpl implements OrderService { ... } // 没有接口的类:走 CGLIB,生成子类代理 @Service public class ReportGen { // 注意:final 方法不会被代理增强 public final void header() { ... } // 这里 AOP 织不入! }

论为什么同类方法内部调用,AOP 不生效

代理是"包在外面的壳"。你用 this.x() 调用同类方法,走的是"本体"不是"代理",自然绕过增强。这是 @Transactional/@Cacheable 失效最常见的原因。解决:自注入(注入自己)、或抽到另一个 Bean。

切点表达式:精确命中要增强的方法

// execution 表达式:修饰符 返回 包.类.方法(参数) @Pointcut("execution(* com.demo..service.*.*(..))") // service 包下所有方法 public void inService() {} // 组合:带注解的切点,按业务标记织入 @Pointcut("@annotation(com.demo.Log)") // 标了 @Log 的方法 public void annotated() {}

实战一:日志/耗时切面

用 @Around 包住方法,前后分别记开始/结束、算耗时。比在每个方法里手写 long t = System.nanoTime() 优雅得多。

@Aspect // 声明这是一个切面 @Component public class LoggingAspect { @Around("execution(* com.demo..service..*(..))") public Object log(ProceedingJoinPoint p) throws Throwable { long t = System.nanoTime(); try { return p.proceed(); } // 必须调用 proceed,否则目标不执行 finally { log.info("{} 耗时 {}ms", p.getSignature(), (System.nanoTime()-t)/1_000_000); } } }

实战二:事务切面与 @Transactional 原理

@Transactional 本质是 AOP 帮你加的 @Around:方法前开连接、设 autoCommit=false,成功 commit、抛异常 rollback。所以 transaction 也是"代理增强",同样受"同类调用失效"约束。

// 声明式事务:一个注解搞定提交/回滚 @Transactional public void transfer(Long from, Long to, BigDecimal amt) { accountRepo.debit(from, amt); accountRepo.credit(to, amt); // 任一步抛异常,整体回滚 }
@Transactional 的失效清单

① 同类方法互调(this 调用绕开代理)。② 吞了异常(catch 后没再抛,事务以为成功)。③ 非 RuntimeException 被吞(默认只回滚unchecked,受检异常不回滚)。④ private 方法无法被代理。⑤ 标注在接口方法上但用 CGLIB时行为差异。

实战三:多切面执行顺序

多个切面命中同一方法时,用 @Order 决定谁先谁后:数值小的前置先执行、后置后执行(像洋葱,外层的先包、后拆)。

@Aspect @Component @Order(1) // 最外层:先鉴权 public class AuthAspect { @Around(...) public Object around(...) { ... } } @Aspect @Component @Order(2) // 内层:后计时 public class TimerAspect { @Around(...) public Object around(...) { ... } }
3

Spring Security / OAuth2 / OIDC:认证授权模型

Authentication · Authorization · Resource Server · JWT

"登录""权限""单点登录"是后端永远逃不开的需求。Security 6 把整套机制收敛成一条过滤器链;OAuth2/OIDC 解决"第三方登录"和"跨系统认证"。这一章把它们从模型讲到落地。

认证 vs 授权:两个常被混为一谈的词

论认证与授权职责不同

认证(Authentication):你是谁(用户名密码、Token)。授权(Authorization):你能干什么(这个接口你能不能调)。Security 用 AuthenticationManager 处理前者、AuthorizationManager 处理后者,二者可以分离部署(资源服务器只做授权校验)。

请求进来先过认证(你是谁),再过授权(你能干啥)。整条 SecurityFilterChain 就是一串过滤器,每个负责一环(CSRF、登录、鉴权……)。

// Security 6 用组件式配置,不再继承 WebSecurityConfigurerAdapter @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .formLogin(Customizer.withDefaults()) .csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")); // 无状态 API 关 CSRF return http.build(); }

用户详情与密码编码

Security 不关心你怎么存用户,只认 UserDetailsService:给它用户名,还它一个 UserDetails(含账号、加密后的密码、权限)。密码必须哈希,用 PasswordEncoder 委托。

@Bean PasswordEncoder encoder() { return new BCryptPasswordEncoder(); } @Bean UserDetailsService users(PasswordEncoder enc) { UserDetails u = User.withUsername("alice") .password(enc.encode("s3cret")) // 入库前也该这样加密 .roles("USER", "ADMIN").build(); return new InMemoryUserDetailsManager(u); }
明文密码是最大的安全事故

数据库绝不能存明文。用 BCrypt / Argon2 等自适应哈希,加盐且抗暴力;登录失败要限流 + 审计,防撞库。曾发生过的泄露事件,十有八九是"明文/弱哈希 + 拖库"。

方法级安全:在业务方法上直接声明权限

// 开启方法级安全后,用注解守在方法上 @PreAuthorize("hasRole('ADMIN')") public void deleteUser(Long id) { ... } // SpEL 还能用入参:只有本人或管理员能改 @PreAuthorize("#id == authentication.name or hasRole('ADMIN')") public void updateProfile(String id, Profile p) { ... }
常用表达式含义
hasRole('X')拥有角色 X(自动加 ROLE_ 前缀)
hasAuthority('SCOPE_read')拥有某权限/作用域
isAuthenticated()已登录
authentication.name当前用户名

OAuth2 授权码模式:第三方登录的标准姿势

"用 GitHub 登录"背后就是授权码流:用户被导向 GitHub → 同意授权 → GitHub 带着授权码跳回你的回调 → 你后端用码换 access_token。码只在后端之间传递,前端拿不到 token,最安全。纯前端/移动端则加 PKCE 防截获。

论为什么不用"密码模式"做第三方登录

密码模式要你把用户账号密码直接交给第三方,等于把钥匙送人,早已不推荐。授权码模式让"授权"与"凭据"分离:第三方只拿到"代表用户访问某资源"的令牌,拿不到密码,用户还能随时在授权方吊销。

授权模式场景
授权码 + PKCE有/无后端的 Web、移动端(当前推荐基线)
客户端凭证服务到服务,无用户(机器账号)
刷新令牌access_token 过期后换新的

资源服务器与 JWT

资源服务器(真正提供 API 的服务)只做一件事:校验令牌合法性,不负责登录。JWT 把用户信息自包含进令牌,资源服务器用签名公钥本地校验,不必每次查库。

// 资源服务器:声明"我信 JWT",并指定怎么验签 @Bean SecurityFilterChain resServer(HttpSecurity http) throws Exception { http.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults())); http.authorizeHttpRequests(a -> a.anyRequest().authenticated()); return http.build(); } // application.yml:用授权服务器的 JWK 公钥集验签 # spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://auth.example.com/.well-known/jwks.json

论JWT 还是不透明令牌?

JWT:自包含、资源服务器免查库、易跨服务,但难吊销(令牌在有效期内一直有效)。不透明令牌:存服务端、可随时吊销,但每次要 introspection 查一次。折中:访问令牌短期(15min)+ 刷新令牌可吊销;敏感操作二次校验。

OIDC 与刷新令牌

OAuth2 只管"授权",OIDC 在其上管"认证":授权服务器额外发一个 id_token(JWT),携带"是谁"。刷新令牌用于 access_token 过期后换新——它必须服务端可控、可吊销。

// 客户端:用授权码换令牌(含 id_token 与 refresh_token) // POST /oauth2/token grant_type=authorization_code&code=xxx&redirect_uri=...&client_id=...&code_verifier=yyy // 刷新: // POST /oauth2/token grant_type=refresh_token&refresh_token=zzz&client_id=...&client_secret=... // 关键:refresh_token 存 HttpOnly Cookie 或后端,绝不进前端 JS 可读取的位置

常见安全陷阱

放行配置与 CORS 的雷

① permitAll() 配错路径(如 /api/** 误放行管理接口),等于门户洞开。② allowedOriginPatterns("*") + allowCredentials(true) 同时开,等于任意站点都能带你的登录态调接口(CSRF 风险)。③ 静态资源误放行导致 actuator/接口文档裸奔。④ 用 .antMatchers 老 API(已废弃),新项目用 requestMatchers。

风险点正确做法
放行路径过宽白名单最小化,按角色细分
CORS 通配 + 凭据限定具体可信源,必要时用 token 而非 cookie
actuator 裸奔生产关掉或加 IP 白名单/认证
令牌存 localStorage用 HttpOnly Cookie 或内存 + 短时效
4

缓存 / 异步 / @Async / 事务传播行为

@Cacheable · @Async · Propagation · Isolation

性能与正确性的三块基石:缓存挡热点、异步解耦重活、事务保一致。Spring 用注解把它们声明化,但每个注解背后都有语义要搞清楚,否则"看起来对了,实际坑了"。

@Cacheable 缓存抽象:别让热点每次打库

Spring 的缓存是抽象层——你只标注解,底层接 Redis/Caffeine/内存都行。第一次查库、结果进缓存;之后再调用同名同参,直接从缓存取。

@Cacheable(value="user", key="#id") // 以 id 为 key 缓存返回对象 public User findById(Long id) { ... } // 第一次走库,之后走缓存 @CachePut(value="user", key="#user.id") // 更新并返回,同时刷新缓存 public User update(User user) { ... } @CacheEvict(value="user", key="#user.id") // 删除时清缓存,防脏读 public void remove(User user) { ... }
注解行为
@Cacheable有就返回缓存,没有才执行并写入
@CachePut总是执行,并把结果写缓存
@CacheEvict执行后清除缓存项
@Caching组合多个缓存操作

缓存与数据库的一致性

论没有完美的"双写一致"

"先更新 DB 还是先删缓存"没有银弹。最常用 Cache-Aside:先更 DB,再删缓存,配合过期时间兜底与延时双删(更新后隔几百毫秒再删一次,清掉并发读回的旧值)。别指望 @CacheEvict 解决所有一致性——按业务容忍度设计,强一致场景考虑分布式锁或走数据库。

缓存穿透 / 击穿 / 雪崩

穿透:查不存在的 key 每次都打库 → 缓存空值或用布隆过滤器。击穿:热点 key 过期瞬间大量请求涌入 → 互斥锁单飞重建。雪崩:大量 key 同一时刻失效 → 加随机过期时间错峰。

@Async 异步执行:把慢活丢给其他线程

@Async 让方法在独立线程执行,调用方不阻塞。但必须配自定义线程池——默认用 SimpleAsyncTaskExecutor 每次 new 线程,高并发会炸。

@Configuration @EnableAsync // 开启异步 public class AsyncConfig { @Bean("bizExecutor") Executor bizExecutor() { ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); ex.setCorePoolSize(8); ex.setMaxPoolSize(16); ex.setQueueCapacity(256); ex.setThreadNamePrefix("biz-"); ex.initialize(); return ex; } } @Async("bizExecutor") // 指定线程池 public CompletableFuture<Void> sendEmail(Long uid) { ... }
@Async 失效的两个经典原因

① 同类方法调用:this.sendEmail() 走本体不走代理(和 @Transactional 同理)。② 返回 void 且内部抛异常:异常被吞,主线程毫无察觉;建议返回 Future 或 CompletableFuture 以便拿到异常。

事件驱动:ApplicationEvent 解耦上下游

下单成功后,短信、积分、风控都想响应。与其在方法里硬编码调用,不如发一个领域事件,各监听器自行订阅——新增需求不改主流程。

record OrderCreatedEvent(Long orderId) {} // 事件就是个数据载体 // 发布 applicationEventPublisher.publishEvent(new OrderCreatedEvent(order.getId())); // 订阅:多个监听器各自处理,互不影响 @EventListener public void onSms(OrderCreatedEvent e) { sms.send(e.orderId()); } @Async @EventListener public void onPoints(OrderCreatedEvent e) { points.add(e.orderId()); }

事务传播行为:方法互相调用时事务怎么算

一个事务方法调另一个事务方法,是共用一个事务,还是各算各的?由 propagation 决定。这是"写对多步业务"的关键。

传播行为语义场景
REQUIRED(默认)有则加入,无则新建绝大多数业务方法
REQUIRES_NEW挂起外层,自己新开一个独立日志/审计,不受主事务回滚影响
NESTED外层事务内的子事务(可回滚到保存点)部分失败不影响整体
SUPPORTS有事务就用,没有就以非事务跑查询也可在事务中复用
NOT_SUPPORTED挂起事务,非事务执行不想被事务绑定的耗时操作
NEVER必须在非事务中,否则报错严禁事务的读取
// 审计日志用 REQUIRES_NEW:哪怕主下单回滚,日志也要落库 @Transactional(propagation = Propagation.REQUIRES_NEW) public void audit(String op) { auditRepo.save(new Audit(op)); } // 主流程:audit 的成败不拖累(也不被拖累于)transfer @Transactional public void transfer(...) { ... audit("transfer"); }

论REQUIRES_NEW 不是"更安全的 REQUIRED"

它会挂起外层事务另开一个——外层回滚不会带走它,外层锁也不保护它。滥用会让数据出现"部分已提交",破坏一致性预期。只在确实需要"独立落库/隔离"时用,如审计、通知。

事务隔离级别与"读到了什么鬼"

级别脏读不可重复读幻读
READ_UNCOMMITTED可能可能可能
READ_COMMITTED无可能可能
REPEATABLE_READ(MySQL默认)无无可能
SERIALIZABLE无无无

论隔离级别越高越慢

SERIALIZABLE 等于给数据加锁串行化,吞吐骤降。多数业务用数据库默认的 REPEATABLE_READ(MySQL)或 READ_COMMITTED(PostgreSQL)就够,只有"对账""库存扣减"等强一致点才局部提升,并用乐观锁兜底。

事务的常见坑:自调用、异常被吞、只读优化

事务回滚没发生?逐条查

① 同类 this 调用:代理没介入,注解形同虚设。② catch 后没抛:事务管理器收不到异常,照常提交。③ 受检异常默认不回滚:需 @Transactional(rollbackFor=Exception.class)。④ 只读查询标 @Transactional(readOnly=true):让 ORM 走只读优化、不开写事务,省开销。

// 正确:明确回滚范围 + 只读优化 @Transactional(rollbackFor = Exception.class) public void settle(Order o) { ... } @Transactional(readOnly = true) public Summary report(Long id) { ... } // 只读,连接可复用、不占写锁
5

测试 / OpenAPI / Actuator 健康检查

@SpringBootTest · MockMvc · Testcontainers · springdoc · Actuator

写完不等于能上线。测试保证行为不漂移,OpenAPI 让前端能调,Actuator 让运维能看。这一章把"可交付"的三件套讲明白。

测试切片:只加载需要的零件,跑得快又稳

全容器启动慢且易抖。Spring 提供"切片"注解,只装配相关组件,其余 mock 掉。

注解加载范围用途
@WebMvcTest仅 Web 层 + MVCController 单测(Service 用 @MockBean)
@DataJpaTest仅 JPA + 内存库仓储层/查询
@JsonTest仅 JSON 序列化DTO 序列化
@SpringBootTest全容器端到端集成

MockMvc 与 @MockBean:断言 HTTP 行为

// 切片测 Controller:Service 被 mock,专注验证路由/参数/响应 @WebMvcTest(UserController.class) class UserControllerTest { @Autowired MockMvc mvc; @MockBean UserService svc; @Test void getById() throws Exception { when(svc.findOne("1")).thenReturn(new User("1", "alice")); mvc.perform(get("/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("alice")); } }

论测试金字塔在 Spring 里怎么落

大量快的单测(切片)→ 少量集成测关键路径(Testcontainers 真库)→ 极少端到端。把"真数据库"放进测试,能抓到 SQL/事务/类型这些单测抓不到的 bug,又不至于每次都全容器启动拖慢 CI。

Testcontainers:用真实中间件做集成测试

// 起一个真实 PostgreSQL 容器,比内存库更接近生产 @Testcontainers @DataJpaTest class UserRepoTest { @Container static PostgreSQLContainer db = new PostgreSQLContainer("postgres:16").withReuse(true); @Test void savesAndReads() { // 真实事务、真实类型映射,连 enum/JSON 列都能验 repo.save(new User("alice")); assertThat(repo.findByName("alice")).isPresent(); } }

覆盖率与测试分层性价比

论别盲目追 100% 覆盖率

覆盖率高 ≠ 测得好。把精力放在核心业务分支(金额计算、状态机、权限判断),getter/setter 的覆盖是虚的。用 JaCoCo 看"哪些分支没覆盖",补的是逻辑不是数字。CI 里设一个合理下限(如增量 80%),防退化。

OpenAPI / springdoc:从代码生成接口文档

手写文档必过期。springdoc-openapi 从注解和类型直接产出 OpenAPI 规格,前端随时能调。

// 标注即文档:summary 进文档,@Valid 约束也同步 @Operation(summary = "创建用户", description = "返回新建用户 id") @PostMapping("/users") public User create(@RequestBody @Valid CreateUserReq req) { ... } // 文档地址:/swagger-ui.html 规格:/v3/api-docs // 分组:用 GroupedOpenApi 按版本/模块切分

Actuator:健康检查与指标,但别裸奔

# 暴露端点:生产只开必要的 management: endpoints.web.exposure.include: health,info,metrics,prometheus endpoint.health.show-details: when_authorized # 详情仅授权可见 metrics.tags.enabled: true // 自定义健康指标:依赖的下游挂了,健康检查就红 @Component public class CacheHealth implements HealthIndicator { public Health health() { return redis.ping() ? Health.up().build() : Health.down().withDetail("redis", "unreachable").build(); } }
actuator 裸奔到公网 = 开门揖盗

默认 /actuator/env 会暴露配置(含可能的密钥占位)、/actuator/heapdump 能下堆转储。生产务必:只暴露 health/metrics/info、show-details: when_authorized、加网络安全组/IP 白名单,绝不让它从公网直连。

6

微服务 Spring Cloud:注册发现 / 配置 / 熔断限流

Service Discovery · Config · Resilience4j · Gateway

单体拆成多个服务后,新问题来了:服务在哪、配置怎么统一、一个挂了会不会拖垮一片。Spring Cloud 给了一套标准化答案。这一章讲清核心四件套。

为什么需要服务治理

论拆分带来的是"分布式的新麻烦"

单体里方法调用是函数跳转;微服务里是网络调用——会超时、会失败、地址会变。于是需要:注册发现(地址动态寻址)、配置中心(一处改处处生效)、熔断限流(防雪崩)、网关(统一入口)。这不是"加功能",是"为网络不可靠买单"。

服务注册发现:地址不用写死

每个服务启动时把自己注册到注册中心(Nacos/Eureka/Consul),调用方从中心发现可用实例。扩容缩容、实例挂掉,地址自动更新。

// 引入 discovery client 后,用服务名代替 IP:Port 调用 @SpringBootApplication @EnableDiscoveryClient // 自动注册并能被发现 public class OrderApp { ... } // 调用方:用逻辑名 "user-service",由负载均衡解析到实例 // GET http://user-service/users/1 (经 LoadBalancer 选一个实例)
注册中心特点适用
Nacos注册 + 配置一体,国内主流新项目、阿里系生态
EurekaAP 优先,Netflix 经典纯注册、简单
ConsulCP、强一致、含 KV/健康检查需强一致、多数据中心

配置中心:一处改,处处生效

把配置从代码里抽出来集中管理,支持环境隔离、动态刷新、版本回滚。改数据库连接不用重新打包部署。

// @RefreshScope:配置变更后,下次注入拿到新值,无需重启 @RefreshScope @Component public class FeatureFlags { @Value("${feature.new-checkout:false}") // 默认值 false private boolean newCheckout; public boolean isNewCheckout() { return newCheckout; } }

论配置中心不是"配置随便改"

动态配置很香,但改错了全集群即时中招。要配权限、审批、灰度(先放一台验证)、变更审计。密钥类配置走 Vault/密钥管理,别和普通过配置混在一起明文存。

声明式调用 OpenFeign

不想手写 RestTemplate + 拼 URL?Feign 用接口 + 注解描述"我要调哪个服务的哪个方法",像调本地方法一样调远程。

// 声明即调用:接口里写好目标服务与方法 @FeignClient(name = "user-service") public interface UserClient { @GetMapping("/users/{id}") UserDTO findById(@PathVariable("id") Long id); } // 使用:直接注入接口,Feign 负责寻址 + 编解码 + 负载均衡 UserDTO u = userClient.findById(order.getUserId());
Feign 默认不重试 + 超时坑

Feign 默认超时要显式配(否则沿用 Ribbon/全局默认值,可能过长把线程占满)。它不自动重试非幂等写——重试"下单"会重复创建。务必区分幂等(GET/查询)与非幂等(POST 创建),并对写操作加幂等键防护。

熔断限流 Resilience4j:别让一个挂拖累一片

// 熔断器:失败率超阈值就"开路",直接快速失败,给下游喘息 @CircuitBreaker(name = "userSvc", fallbackMethod = "userFallback") public UserDTO user(Long id) { return userClient.findById(id); } public UserDTO userFallback(Long id, Exception e) { // 降级:返回兜底 return UserDTO.offline(id); } // 其他模式:@RateLimiter(限流)、@Bulkhead(隔离舱,限并发)、@Retry(重试)
模式解决什么触发
CircuitBreaker下游持续失败,快速失败防雪崩失败率 / 慢调用率超阈值
RateLimiter限制单位时间请求数超过配额直接拒
Bulkhead隔离不同下游的并发,互不挤占并发数超上限排队/拒
Retry瞬时故障自动重试异常 + 退避策略

网关 Spring Cloud Gateway:统一入口

网关是系统的"前台":路由转发、鉴权、限流、灰度、日志都在这里收口,后端服务只管业务。

# 路由配置:把 /api/order/** 转给 order-service spring: cloud.gateway.routes: - id: order uri: lb://order-service # lb:// 走服务发现负载均衡 predicates: - Path=/api/order/** filters: - name: RequestRateLimiter # 网关层限流 - StripPrefix=1 # 去掉 /api 前缀再转发

论网关不是万能胶

鉴权放网关能统一,但细粒度业务权限仍应在服务内(零信任:不轻信"网关已验过")。限流在网关做粗粒度(按路由),服务内做细粒度(按用户/接口)。网关本身要 HA——它挂了全站进不来,记得多副本 + 健康检查。

结
本页要点

IoC 把对象创建与装配交给容器:构造器注入最稳,Bean 有 singleton/prototype 等作用域与完整生命周期钩子,循环依赖靠三级缓存但构造器注入无解,自动配置用 @Conditional 实现"有默认、可覆盖"。AOP 用 JDK/CGLIB 代理织入横切逻辑,@Transactional/@Cacheable/@Async 都是代理增强,同类 this 调用会失效。Security 6 用过滤器链统一认证授权、密码必须哈希;OAuth2 管授权、OIDC 管认证,JWT 自包含但难吊销,访问令牌短效 + 刷新令牌可吊销。缓存用 Cache-Aside + 过期兜底、警惕穿透/击穿/雪崩;事务传播以 REQUIRED/REQUIRES_NEW 为核心、隔离级别越高越慢、注意自调用与异常吞噬。测试靠切片 + Testcontainers;OpenAPI 与 Actuator 补全可观测但别裸奔。Spring Cloud 用注册发现、配置中心、OpenFeign、Resilience4j、Gateway 解决分布式寻址、动态配置、防雪崩与统一入口。

自测 · 看你是否真懂

1.为什么 Spring 推荐构造器注入而不是字段注入?

查看答案

构造器注入让依赖在 new 时就齐活、不可变、强制非空,对象不会处于"半残"状态;单测可以不依赖容器直接传入 mock。字段注入依赖在构造后才被容器回填,且难测、易隐藏循环依赖。

2.@Transactional 标注在一个 Service 的私有方法上、或被同类其他方法 this 调用,会生效吗?为什么?

查看答案

都不生效。Spring 事务是靠 AOP 代理织入的,私有方法无法被代理增强;而同类 this 调用走的是"本体"而非"代理",绕过了事务拦截器,注解形同虚设。应抽成独立 Bean 或自注入来调用。

3.OAuth2 和 OIDC 的区别?JWT 做访问令牌有什么隐患,怎么缓解?

查看答案

OAuth2 解决"授权"(代表用户访问资源),OIDC 在其上解决"认证"(确认用户身份,通过 id_token 携带)。JWT 自包含、服务端不存,难以在到期前吊销(用户登出/被盗仍可用)。缓解:访问令牌短效(如 15 分钟)+ 可吊销刷新令牌;敏感操作二次校验。

4.事务传播行为 REQUIRED 和 REQUIRES_NEW 有什么本质区别?什么时候该用 REQUIRES_NEW?

查看答案

REQUIRED 有外层事务就加入、没有就新建;REQUIRES_NEW 会挂起外层事务、自己开一个独立事务,外层回滚不影响它、它也不受外层锁保护。只在需要"独立落库/隔离"时用,如审计日志、通知,滥用会破坏一致性预期。

5.为什么推荐 Testcontainers 做集成测试?缓存的"穿透/击穿/雪崩"分别怎么防?

查看答案

Testcontainers 用真实数据库/中间件容器,能抓到单测覆盖不到的 SQL、事务、类型、驱动兼容问题,比内存库更接近生产。缓存穿透:缓存空值或布隆过滤器;击穿:互斥锁单飞重建热点;雪崩:加随机过期时间错峰。

下一步往哪走

路学完 Spring 进阶之后

① 看系统设计页:限流、熔断、缓存策略在这里系统化,与 Resilience4j/Gateway 呼应。

② 看 DevOps 页:可观测三支柱、SLO、容器编排是这套后端的运维底座,Actuator 数据正喂给 Prometheus。

③ 动手搭一个:带 Security + OAuth2 + Redis 缓存 + Testcontainers + 熔断的最小可运行服务,把本章每个坑都亲手踩一遍。