Java 业务系统工程化设计:从反射、Spring 到缓存、异步与多存储架构
业务系统真正困难的地方,往往不是把一个接口写出来,而是让代码在长期演进、并发访问、依赖故障、版本升级和流量增长之后仍然保持可维护、可扩展、可恢复和可观测。本文从 Java 反射、泛型与注解的语言边界出发,逐步进入 Spring IoC/AOP、接口设计、缓存与异步可靠性、生产可观测性以及 MySQL、Redis、Elasticsearch、时序数据库协同设计,整理一套面向真实业务系统的工程化思考框架。
从“代码能跑”到“系统可控”
业务开发很容易陷入一种错觉:Controller 能返回 JSON、数据库能写进去、测试用例跑过了,就代表功能完成了。实际上,真正进入生产环境以后,问题会沿着完全不同的方向出现:反射拿到了源码里看不到的方法、单例 Bean 保存了请求状态、AOP 吃掉异常导致事务提交、环境变量覆盖配置文件、缓存同时失效压垮数据库、MQ 重复投递造成重复发放、服务已经“活着”却无法接单,或者查询需求被硬塞给了根本不擅长这种查询的数据库。
这些问题看起来跨度很大,本质上却都指向同一件事:不要只理解自己写下的那几行代码,还要理解编译器、容器、代理、缓存、消息系统和存储系统在运行时替你做了什么。
flowchart LR
A[Java 语言层\n反射 / 泛型 / 注解] --> B[Spring 运行时\nIoC / AOP / Bean / 配置]
B --> C[代码与接口设计\n抽象 / 版本 / 同步异步]
C --> D[运行时可靠性\n缓存 / MQ / 补偿]
D --> E[生产就绪\nHealth / Metrics / Tracing]
E --> F[数据架构\nRDBMS + Cache + Search + TSDB]
本文中的一些案例最早形成于 Spring Boot 2.x、Spring Cloud Netflix Ribbon 仍较常见的时期。核心原理今天仍然成立,但实现细节已经演进:新项目应使用 Spring Cloud LoadBalancer 而不是 Ribbon;Spring Boot 3.x/4.x 的 Actuator 与 Micrometer Observation 已形成更完整的可观测体系;Spring Framework 7 已提供内建 API Versioning。理解旧实现的价值,不是照抄旧配置,而是借它看清运行时边界。
Java 高级特性真正危险的地方在运行时
反射、泛型和注解经常被称为“高级特性”,但它们真正难的地方不在 API 多,而在于源码、编译后字节码和运行时元数据并不完全相同。当代码开始依赖反射动态扫描类结构时,这种差异会直接变成业务 Bug。
反射调用重载:方法在 invoke 之前就已经选完了
假设一个类中存在两个重载:
1 | |
正常 Java 调用会根据编译期重载规则选择方法;反射不是。反射调用分成两个阶段:先通过方法签名拿到 Method,再调用 invoke。真正决定重载的是第一步。
1 | |
第一段拿到的是 age(int),第二段拿到的是 age(Integer)。invoke 阶段允许必要的装箱/拆箱,并不会重新做一次“根据实参选择最合适重载”的过程。
所以,当框架通过反射调用重载方法时,不要把 invoke 的参数当作方法选择条件。方法名 + 参数类型数组才是反射查找阶段的方法签名。
getMethods 和 getDeclaredMethods 不是同一种扫描语义
反射扫描成员时,最常见的误解是把 getXxx 和 getDeclaredXxx 只理解成“一个能看 private,一个不能”。实际上它们的继承语义也不同。
| API | 当前类 public | 当前类非 public | 继承的 public 方法 |
|---|---|---|---|
getMethods() |
是 | 否 | 是 |
getDeclaredMethods() |
是 | 是 | 否 |
Field、Constructor、Annotation 等 API 也有相似命名,但具体继承规则并不完全一样,不能拿 Method 的结论机械套用。
如果业务逻辑依赖“扫描出来必须正好一个方法”,最好显式做断言,而不是 .findFirst() 以后假设永远正确。反射代码一旦拿错元数据,通常不会像普通代码那样容易被编译器发现。
泛型擦除会让源码里一个方法变成字节码里的两个方法
考虑一个泛型父类:
1 | |
源码里 Child 明明只有一个 setValue(String),但编译器为了同时满足“泛型擦除后的父类签名”和“子类具体类型签名”,会生成桥接方法。等价地看,字节码中会出现:
1 | |
于是下面的反射代码会看到两个方法:
1 | |
如果框架只是按方法名扫描再逐个调用,就可能把真实方法和桥接方法都调用一遍。
更隐蔽的一种错误是子类直接写成原始类型:
1 | |
此时父类擦除后的方法是 setValue(Object),子类的 setValue(String) 根本不是重写,而是一个新的重载。给重写方法始终加 @Override,可以让这种错误在编译期暴露,而不是上线后靠反射日志发现。
对于反射扫描泛型方法,常见过滤方式是:
1 | |
不要无脑过滤所有 synthetic 成员,因为编译器、字节码增强框架和语言特性都可能生成 synthetic 成员;应该明确自己真正需要排除的成员类型。
@Inherited 只解决类注解继承,而且只解决 Java 语言层的一小部分问题
自定义注解如果不声明 @Inherited,子类通过 Class#getAnnotation 不会得到父类上的注解:
1 | |
即使加了 @Inherited,它也只对类上的注解、沿父类继承链生效:
- 不会让重写方法自动继承父类方法上的注解;
- 不会因为接口上有注解,就让实现类自动获得注解;
- 不会替你处理泛型桥接方法。
Spring 自己有一套更强的“合并注解”语义。框架代码需要查找父类、接口、元注解和桥接方法时,通常更适合使用 AnnotatedElementUtils:
1 | |
这也解释了为什么写 Spring 扩展时,不应该把 JDK 反射的 getAnnotation 语义直接等同于 Spring 的注解搜索语义。
JDK 9 以后模块系统强化了封装。对自己应用中的类使用反射通常没问题,但对未开放模块中的成员强行
setAccessible(true)可能失败。框架代码应尽量依赖公开 API、访问器或明确的模块开放配置,而不是把“反射什么都能访问”当作前提。
Spring 的核心不是注解,而是 Bean、代理和运行时边界
Spring 的 IoC 和 AOP 经常被记成一组注解:@Component、@Autowired、@Aspect、@Transactional。这样记很容易会用,却很难排错。更准确的理解是:IoC 决定谁管理对象,AOP 决定调用是否经过代理链。
IoC 带来的真正能力是“对象可被容器重新组织”
对象交给容器以后,容器才有机会在业务代码之外完成:
- 依赖注入和实现替换;
- 生命周期与 Scope 管理;
- BeanPostProcessor 增强;
- AOP 代理;
- 自动配置和条件装配;
- 监控、事务、安全等横切能力。
因此,排查 Spring 问题时,一个非常有效的第一问是:我现在操作的这个对象,真的是 Spring 管理的 Bean 吗?
AOP 四个概念要放到一次方法调用里理解
| 概念 | 在 Spring AOP 中的含义 |
|---|---|
| Join Point | 可以被增强的位置;Spring AOP 中核心就是方法执行 |
| Pointcut | 哪些方法需要被匹配 |
| Advice | 匹配后要执行什么,例如 before、after、around |
| Aspect | Pointcut + Advice 形成的横切模块 |
Spring AOP 本质是代理。调用没有经过代理,就没有切面;对象不是 Bean,也通常没有容器自动创建的代理。
有状态 Bean 默认做成单例,是典型的生产事故来源
Spring Bean 默认是 singleton。单例并不意味着“只有一个类”,而是容器通常只维护一个实例。如果这个实例保存了跨调用可变状态,例如:
1 | |
它就不再是无状态服务。并发请求会同时操作同一个 List,长期累积还可能导致内存不断增长。
修复时仅仅给子 Bean 加 prototype 也不一定够。如果一个 singleton Controller 在初始化时直接注入 prototype Bean,那么它拿到的可能仍然只是初始化阶段创建出来的那一个实例。
一种方式是 scoped proxy:
1 | |
注入 singleton 的实际上是代理,真正调用时由代理再解析目标对象。
如果不希望依赖 scoped proxy,可以使用 ObjectProvider,比业务代码直接拿 ApplicationContext 当 Service Locator 更清晰:
1 | |
核心不是“prototype 比 singleton 高级”,而是生命周期必须和状态语义一致。绝大多数 Service 应尽量无状态,从根源上避免 Scope 复杂度。
切面顺序错误,真的可以让事务失效
Spring 声明式事务本身就是一条 AOP 拦截器链。自定义监控切面如果也包住同一个 Service,多个 Advice 的顺序就不再是审美问题,而是业务语义。
假设 Service 抛出异常:
sequenceDiagram
participant C as Caller
participant M as MetricsAspect
participant T as TransactionInterceptor
participant S as Service
C->>M: invoke
M->>T: proceed
T->>S: invoke
S-->>T: throw Exception
T->>T: rollback
T-->>M: rethrow
M->>M: record metrics / log
M-->>C: rethrow or translate
如果监控切面位于事务切面里面,并且它把异常 catch 后直接返回默认值,事务拦截器看到的就会是“正常返回”,自然不会回滚。
@Order 的数值越小,优先级越高。高优先级切面进入时更早,退出时更晚。因此,若监控切面需要观察完整事务结果,通常应该让事务切面位于它内部,同时不要在事务看见异常之前把异常吃掉:
1 | |
如果业务真的要求“忽略异常继续执行”,应该明确这会改变事务边界和方法契约,不要把这种故障策略偷偷塞进一个通用 Metrics 切面里。
AOP 切不到时,先确认目标对象是不是 Bean
一个非常经典的 Spring Cloud 问题是:代码里明明能看到某个 Client 实现,within(Client+) 却切不到。原因可能不是 Pointcut 写错,而是那个真正执行请求的对象只是某个 Bean 内部 new 出来的 delegate,本身没有交给 Spring 管理。
这类问题的排查顺序比背源码更重要:
1 | |
然后继续确认:
- 容器中到底有哪些该类型 Bean;
- 当前注入的是目标对象还是代理;
- 代理是 JDK 动态代理还是 CGLIB;
- 真正执行逻辑的是 Bean 本身,还是 Bean 内部的 delegate;
- 自动配置是否因为 classpath、URL、配置属性变化走了另一条分支。
早期 Spring Cloud OpenFeign + Ribbon 的一个典型分支是:未指定 URL 时使用负载均衡客户端 Bean,指定固定 URL 后则可能拆出内部 HTTP delegate;AOP 能切前者却切不到后者。这个案例今天不应该原样复刻,因为 Ribbon 已不再是新项目推荐方案,当前 Spring Cloud 使用 Spring Cloud LoadBalancer,HTTP Client 组合也已经变化。但它留下的结论完全没有过时:AOP 只能增强它实际代理到的对象,不能因为某个类出现在 Spring 项目源码里,就假定它一定是 Bean。
现代项目扩展 Feign 时,也应优先使用框架公开的扩展点、拦截器和观测能力,而不是针对内部实现类写脆弱的切点。
JDK 代理和 CGLIB 的限制仍然需要知道
Spring Framework 核心支持 JDK 动态代理与 CGLIB 类代理。Spring Boot 默认通常启用基于类的代理,可通过:
1 | |
切换为接口代理。
两种代理方式的边界不同:
- JDK 动态代理要求通过接口暴露能力;
- CGLIB 通过生成子类代理,因此
final类和final方法无法按普通继承方式覆盖增强; - 代理语义会影响运行时类型判断和反射扫描;
- 在 Java Module System 下,类代理还会受到模块开放限制。
所以遇到“为什么这个 Bean 不能代理”,不要只看 @Aspect,还要看类型结构和代理策略。
Spring 配置失效:很多时候不是没加载,而是被更高优先级配置覆盖了
Spring Boot 把配置统一抽象成 Environment,其中包含多个有顺序的 PropertySource。查询属性时,本质逻辑可以理解为:按优先级遍历配置源,第一个找到非空值的源获胜。
这会制造两个非常常见的错觉。
环境变量会以 relaxed binding 的方式映射到配置项
例如:
1 | |
系统环境中如果存在:
1 | |
应用最终管理端口可能是 12345。Spring Boot 对配置名称做了 relaxed binding,点号、短横线、下划线和大小写之间可以按照规则映射。
另一个经典冲突是:
1 | |
JVM 本身就可能存在 user.name 系统属性,因此最终读取到的值未必来自 application.properties。
遇到这种问题,不要先怀疑 @Value。直接检查实际 PropertySource:
1 | |
生产环境不要把密钥、Token、数据库密码直接打进日志。需要查看配置来源时,可以只输出配置源名称和是否存在,敏感值必须脱敏。
不要把 Spring Boot 内部实现当稳定扩展 API
Spring Boot 2.x 内部通过 ConfigurationPropertySourcesPropertySource、SpringConfigurationPropertySources 等适配层参与配置绑定和 relaxed binding。理解这套实现有助于源码排错,但业务代码不应该依赖这些内部类。
真正稳定的认知模型是:
flowchart LR
E[Environment] --> P[MutablePropertySources]
P --> P1[Command Line / System Properties]
P --> P2[Environment Variables]
P --> P3[Config Files]
P --> P4[Default Properties]
E --> R[Property Resolver]
R -->|按顺序查找| P
具体优先级会随着 Spring Boot 版本和配置方式发生细节变化。与其背一张可能过期的优先级表,不如在出现冲突时直接检查当前进程实际装载的 PropertySource,并配合安全配置后的 Actuator env/configprops 端点排查。
消除重复代码,不是为了“炫设计模式”,而是降低修改成本
重复代码最危险的地方不是难看,而是同一个规则被复制到多处以后,系统再也没有一个唯一真相。一个 Bug 修三处漏一处,或者复制逻辑时把原本不同的字段改成了一样,都会让维护成本指数上升。
模板方法负责固定流程,策略负责保留差异
购物车是一个典型例子:不同用户类型的“优惠计算”和“运费计算”不同,但商品装载、总价汇总、应付金额计算完全一样。
重复三份完整流程,不如把稳定流程放在父类:
1 | |
普通用户、VIP、内部用户只实现差异部分。这样修改公共计算逻辑只需要改一处。
选择实现时,也没有必要堆一串 if-else。早期实现常通过 Bean 名称拼接后从 ApplicationContext 获取对象;更推荐让工厂显式管理策略:
1 | |
这里实际组合了模板方法、策略和工厂思想。重点不是模式名称,而是实现开闭原则:新增类型时增加新实现,尽量不要回头修改已经稳定的主流程。
注解 + 反射适合解决“同一算法,不同元数据”
如果多个三方接口都要求“字段按固定顺序、固定长度、固定格式拼接”,每个接口都手写字符串拼接会产生大量细粒度重复代码。
可以把变化的部分变成元数据:
1 | |
接口对象只描述数据:
1 | |
统一序列化器负责扫描、排序和格式化:
1 | |
于是“字段规则”与“规则执行算法”被拆开:新增接口通常只新增 POJO 和注解,不再复制整套拼接、签名、请求发送逻辑。
这种方式特别适合协议映射、数据导入、字段校验、固定格式报文等场景。但如果规则本身越来越复杂,不要继续往注解里塞一门 DSL,应该及时升级为显式配置模型或策略对象。
DTO/DO 手工复制字段,是最没有收益的一类重复代码
几十个字段连续 setXxx(getXxx()) 看起来简单,实际上非常容易出现:
commentable和complainable互相写反;- 纬度误赋成经度;
- 从目标对象自己取值再 set 回去;
- 新增字段后只改了一边。
Spring BeanUtils.copyProperties 能减少样板代码,但它是运行时复制,对类型不兼容、复杂映射和重构安全性支持有限。对于稳定的业务映射,当前更常见的选择是 MapStruct,在编译期生成 Java 代码:
1 | |
编译期映射最大的价值不是“快一点”,而是很多映射错误能更早暴露,生成代码也可以直接检查。复杂字段映射仍然需要单元测试,尤其是金额、时间、枚举和嵌套对象。
API 是系统之间的语言,最怕同一个字段说两种话
接口设计混乱通常不是因为 JSON 不够漂亮,而是服务端和客户端没有对齐“什么代表成功、什么代表失败、下一步应该看哪个字段”。
HTTP 状态、业务结果和领域状态必须分层
最糟糕的响应会同时出现:
success=true;code却是错误码;message=OK;data.status=Cancelled。
调用方根本不知道哪个字段权威。
更清晰的模型应该把三个层次分开:
| 层次 | 负责表达什么 | 典型信号 |
|---|---|---|
| HTTP/协议层 | 请求是否被正确接收、认证、路由和处理 | 2xx / 4xx / 5xx |
| 应用业务层 | 当前 API 的业务动作是否成功 | code、success、业务错误类型 |
| 领域数据层 | 成功后的业务实体状态 | data.status、orderId 等 |
需要修正一个常见的过度简化:非 200 并不代表请求一定没有到业务服务。 4xx/5xx 可能由网关、容器,也可能由业务服务自己返回,而且完全可以带错误响应体。真正应该统一的是客户端处理契约,而不是强行规定“所有业务错误都返回 200”或“所有错误都必须映射成某个 HTTP 状态”。
一个简单的统一结构可以是:
1 | |
关键约束要写清楚:
success=false时客户端不继续按成功结构解析data;code是稳定的机器可读业务码;message是面向人的描述,不应该成为客户端分支判断依据;- 下游订单服务、支付服务的内部错误码不能原样透传给外部调用方,应转换为当前服务自己的错误语义;
- 服务端日志保留下游真实错误,外部契约只暴露必要信息。
统一响应包装应该在框架层完成,但要允许显式退出
业务 Controller 如果每个方法都手工创建 ApiResponse,很快又会出现新的重复代码。可以通过 ResponseBodyAdvice 和统一异常处理集中完成:
1 | |
文件下载、SSE、流式响应、第三方协议兼容接口通常不应该被统一 JSON 包装,需要像 @NoApiResponse 这样的显式退出机制。
如果采用 Spring 6+,公开 REST API 的错误模型也可以考虑 ProblemDetail;不要同时维护两套互相冲突的错误语义。
API 版本策略必须一开始统一
常见版本入口有三类:
| 方式 | 示例 | 特点 |
|---|---|---|
| URL Path | /api/v2/orders |
最直观,网关、文档、缓存都容易观察 |
| Header | API-Version: 2 |
URL 干净,适合内部 API 或统一客户端 |
| Query Parameter | /orders?version=2 |
实现简单,但容易与业务查询参数混在一起 |
真正危险的不是选错方式,而是团队里同时出现 /api/item/v1、/api/v1/shop、/v1/api/user 三种风格。
早期 Spring MVC 如果希望统一版本控制,通常需要扩展 RequestMappingHandlerMapping,在注册映射时把自定义 @APIVersion 合并到路径。这种思路仍然值得理解,但它依赖框架内部映射模型,升级成本不低。
Spring Framework 7 已经内建 API Versioning,可以直接配置版本来源:
1 | |
Controller 可以直接声明版本:
1 | |
如果项目仍然运行在 Spring Framework 6.x,则继续使用统一 Path/Header 约定、网关版本路由或经过充分测试的自定义 HandlerMapping。不要把 7.x API 直接复制到 6.x 项目里。
一个接口要么同步,要么真正异步,不要返回“半成品”
一个常见反模式是:文件上传内部启动两个异步任务,分别上传原图和缩略图,然后只等 1 秒。谁先完成就返回谁,超时的字段留空。
这种接口表面响应很快,实际契约却变得不可预测:同一个成功响应有时有原图 URL,有时只有缩略图 URL。
同步 API 应该保证:返回成功时需要的数据已经完成;超时由客户端、网关和服务端一起定义。
真正异步的 API 应该拆成任务模型:
1 | |
异步任务结果不能只放在进程内 HashMap 中。生产设计还要明确:
- taskId 唯一性和幂等键;
- 任务状态持久化;
- 失败原因;
- 重试规则;
- 任务过期和清理;
- 客户端轮询、回调或事件通知方式。
接口名不是最重要的,可观察且稳定的状态机才是异步 API 的真正契约。
Redis 做缓存,问题从来不只是 GET 和 SET
缓存用更快、更贵的介质换取更低延迟和更少后端计算。它提高了系统吞吐,但也同时制造了一个新的状态副本,因此缓存设计真正难的是:失效、并发、一致性和容量。
把 Redis 当缓存时,必须假设缓存随时可能丢
Redis 完全可以在某些架构中承担主要数据存储,但如果当前角色明确是“缓存”,就应该遵守缓存语义:
- 数据有可恢复的原始来源;
- 缓存丢失不应造成业务数据不可恢复;
- 设置内存上限,明确
maxmemory-policy; - 为缓存 Key 设计 TTL 或主动刷新机制;
- 监控命中率、内存、淘汰量和大 Key/热 Key。
常见淘汰策略包括 allkeys-lru、allkeys-lfu、volatile-lru、volatile-lfu、volatile-ttl、noeviction 等。不同 Redis 版本和发行版还可能提供额外策略,应该以实际部署版本为准。选择策略之前先回答一个问题:哪些 Key 可以被淘汰,哪些 Key 一旦被淘汰就会让业务逻辑出错? 如果后者存在,就说明系统把缓存和主数据职责混在了一起。
另外,不要在业务请求链路里用 KEYS pattern 做搜索。它是按 Key 空间扫描的操作,大数据量下成本高,而且会影响同一实例上的其他请求。需要渐进遍历时使用 SCAN;需要业务搜索时,让真正擅长搜索的索引系统来做。
缓存雪崩、击穿、穿透是三种不同故障
| 问题 | 发生了什么 | 典型解决方向 |
|---|---|---|
| 缓存雪崩 | 大量 Key 同时失效,大量请求同时回源 | TTL 抖动、分批刷新、缓存高可用 |
| 缓存击穿 | 单个极热 Key 失效,大量并发打同一条后端数据 | single-flight、锁、限流、逻辑过期 |
| 缓存穿透 | 请求的数据本来就不存在,每次都绕过缓存查 DB | 空值缓存、Bloom Filter、参数校验 |
雪崩:不要让一批 Key 在同一秒死亡
批量初始化缓存时全部设置 30 分钟 TTL,会让它们在 30 分钟后集体失效。简单有效的做法是引入随机抖动:
1 | |
另一类做法是“逻辑不过期 + 后台主动刷新”,让在线请求永远读取已有快照,刷新过程逐步更新缓存。这适合可以全量驻留、允许短时间旧数据的场景。
无论哪种方案,都要保留回源逻辑。所谓“永不过期”只是应用不主动设置 TTL,不代表 Redis 不会因为故障、淘汰、集群切换或人为操作丢失数据。
击穿:让同一个 Key 的回源合并起来
热点 Key 失效时,理想效果不是 1000 个线程同时查数据库,而是一个线程加载,其他线程等待、读取旧值或快速失败。
使用分布式锁时一定要做二次检查:
1 | |
分布式锁并不是唯一答案。单节点本地 single-flight、Semaphore 限制回源并发、逻辑过期 + 异步刷新、返回短时间旧值,都可能比“所有请求全局串行”更适合高吞吐系统。
穿透:不存在的数据也需要有缓存语义
数据库明确不存在的对象如果每次都返回 null,缓存层必须区分:
- “Key 没缓存”;
- “Key 已确认不存在”。
可以缓存一个短 TTL 的特殊空值:
1 | |
如果非法 Key 空间巨大,可以在缓存前增加 Bloom Filter。Bloom Filter 的特点是:
- 判断“不存在”时可以确定不存在;
- 判断“可能存在”时可能有假阳性;
- 自己不保存完整原值;
- 需要处理新增数据同步、重建和误判率配置。
实际工程中可以 Bloom Filter + 空值缓存组合使用:大部分非法请求在前置过滤器被拒绝,少量假阳性到数据库后再缓存短期 NODATA。
缓存一致性最实用的基线是 Cache-Aside
常见四种写法里,最稳妥的基线通常是:
- 更新数据库;
- 数据库事务提交后删除缓存;
- 后续读取 miss 时重新从数据库加载。
不要先更新缓存再写数据库;数据库失败会留下假新数据。也不要把“更新数据库后更新缓存”当默认策略,并发写顺序一旦交错,就可能把旧值覆盖回缓存。
在 Spring 中至少要保证删除动作发生在事务提交之后:
1 | |
这仍然不是强可靠方案:进程可能在 DB commit 后、Redis delete 前崩溃。对一致性要求高的场景,需要把“失效事件”做成可恢复事件,例如事务 Outbox、CDC、可靠消息或可重试任务。
缓存本来就是为了牺牲部分一致性换取性能。设计前应该明确允许的陈旧窗口,而不是在引入缓存之后又试图用无限复杂的锁恢复成数据库级强一致。
异步处理的第一原则不是快,而是闭环
同步调用的失败通常会直接返回给调用方;异步消息把调用方和处理方解耦以后,失败不会消失,只是变得更晚、更分散、更难观察。
主线 MQ 必须有补偿备线
用户注册后发欢迎短信是一个典型的“主流程同步落库,分支流程异步执行”场景:
flowchart LR
U[User API] -->|1. 写入| DB[(User DB)]
U -.->|2. 发布注册事件| MQ[(MQ)]
MQ -.->|3. 消费| M[Member Service]
DB -->|4. 扫描待补偿数据| J[Compensation Job]
J -->|5. 调用同一幂等处理逻辑| M
主线可能在这些位置失败:
- 数据库提交成功,消息发送失败;
- Broker 短暂不可用;
- 消息投递或消费失败;
- 消费者处理异常;
- MQ 长时间堆积后人工先做了补偿。
因此异步处理必须回答两个问题:
消息漏了怎么办?消息重复了怎么办?
补偿 Job 可以按主业务表扫描“已经发生但下游未完成”的数据,按 offset/时间窗口分批补偿。生产实现还要保证:
- offset 或游标持久化;
- 多节点任务不重复或重复也安全;
- 补偿吞吐足以覆盖最坏情况下的主线吞吐;
- 主线与补偿线调用同一个领域处理函数,避免两套逻辑逐渐漂移。
如果能调整架构,事务 Outbox 是更常见的升级方案:业务数据和事件记录在同一数据库事务提交,再由 relay/CDC 异步发送到 MQ。它可以显著缩小“DB 已提交但消息没发出去”的窗口,但消费者幂等依然不能省。
消费者幂等不是优化项,是消息系统的基本要求
MQ 重投、网络重试、补偿任务、人工补偿都会制造重复消息。真正的幂等不能依赖进程内 ConcurrentHashMap,应该使用业务唯一键或持久化处理记录:
1 | |
处理流程可以是:先尝试写入幂等记录,唯一键冲突代表已经处理过;只有首次成功的请求继续发券、记账或更新状态。
尤其涉及资金、库存、积分、优惠券的场景,“MQ 一般不会重复”不是可接受的设计前提。
RabbitMQ 的广播和工作队列,差别在“队列”而不是消费者数量
如果同一个服务有两个实例,希望一条消息只被其中一个实例处理,那么两个实例应该监听同一条队列,形成 competing consumers。
如果会员服务和营销服务都要收到一份新用户事件,那么应该为两个逻辑消费者分别建立队列,再共同绑定到 Fanout Exchange:
flowchart LR
P[User Service] --> X{{Fanout Exchange}}
X --> MQ1[(member.queue)]
X --> MQ2[(promotion.queue)]
MQ1 --> M1[Member instance 1]
MQ1 --> M2[Member instance 2]
MQ2 --> P1[Promotion instance 1]
MQ2 --> P2[Promotion instance 2]
这里同时实现了两种语义:
- 不同业务服务之间是广播,每个队列得到一份消息;
- 同一业务服务的多实例之间是工作队列,一条消息只由一个实例消费。
一个常见错误是每个实例创建自己的匿名队列,然后都绑定到 Exchange。结果原本想做“多实例负载均衡”,却变成了“每个实例都消费一遍”。另一个错误是所有业务服务共用同一条队列,结果广播事件被会员服务和营销服务抢着消费。
永久失败的消息不能无限 requeue
“处理失败就重新入队”如果没有上限,会让毒消息永久在队列里循环,正常消息反而得不到消费。
合理流程通常是:
flowchart LR
Q[(业务队列)] --> C[Consumer]
C -->|成功| ACK[ACK]
C -->|失败| R{还有重试次数?}
R -->|是| B[退避后重试]
B --> C
R -->|否| D[(Dead Letter Queue)]
D --> A[报警 / 人工或离线处理]
Spring AMQP 可以通过重试拦截器 + RepublishMessageRecoverer 实现类似策略:
1 | |
具体 API 会随 Spring AMQP 版本演进,但原则不变:有限重试、指数退避、最终隔离、可观测、可重放。
除了消息数量,还应该监控最老消息年龄、消费速率、redelivery 比例、DLQ 增长速度。队列“只有 1000 条消息”不一定严重;如果最老消息已经等了 40 分钟,问题可能已经很大。
生产就绪不是“加个 health 接口”,而是建立完整可观测性
一个能响应 TCP 或 HTTP 的进程,不一定能正常提供业务。生产就绪至少需要三类能力:
- 平台知道实例是否应该被重启、是否可以接收流量;
- 运维和开发能看到内部关键资源状态;
- 出问题时能从指标定位范围,再用追踪和日志下钻到具体请求。
Actuator 是入口,但生产环境绝不能照 Demo 全开放
Spring Boot Actuator 提供 health、info、metrics、mappings、configprops、env、threaddump 等大量端点。演示环境经常配置:
1 | |
生产环境不要这样做。更合理的基线是只暴露真正需要的端点,并对管理端口做网络和认证隔离:
1 | |
env、configprops、日志级别修改、heap dump 等能力可能暴露敏感信息或产生高风险操作,必须按最小权限原则控制。
Liveness 和 Readiness 不能混成一个“健康”概念
早期系统喜欢把数据库、Redis、下游 HTTP 服务都放进一个 health,然后只要任何依赖失败就把应用判定 DOWN。在 Kubernetes 环境里,这种策略可能引起级联故障。
现代语义应该分开:
- Liveness:进程自身是否已经进入无法恢复的坏状态。失败通常意味着应该重启实例。不要因为共享数据库暂时挂了就让所有实例同时被 Kubernetes 重启。
- Readiness:当前实例是否适合接流量。关键依赖不可用时,可以选择让实例暂时退出流量,但要评估共享依赖故障会不会导致整个服务所有副本同时 Unready。
Spring Boot 可以暴露:
1 | |
健康检查不是越深越好。对外部服务做探测时必须设置严格超时,避免健康检查本身变成高频同步依赖调用。
对线程池、队列等内部资源,Health 和 Metrics 的职责不同
一个线程池队列已经完全满了,可以影响 Readiness;但队列当前长度、活跃线程数、完成任务数更适合做连续 Metrics。
自定义 HealthIndicator 可以表达“是否还能工作”:
1 | |
但真正用于趋势分析时,应把 activeCount、queueSize、completedTaskCount、reject 数量等注册成 Meter。这样可以在“队列还没彻底满”之前就发现它持续逼近上限。
Metrics 不是“打几个计数器”,而是建立可解释的业务模型
订单系统最有用的指标往往不是 CPU,而是:
order.received;order.success;order.failed{reason=...};order.duration;delivery.received/success/failed/duration。
这样一张图就能区分:是入口没流量、下单失败、下单变慢、消息没发出去,还是配送消费者停工。
Micrometer 常见 Meter 语义:
| 类型 | 适合表达 |
|---|---|
| Counter | 累积事件次数,例如请求数、失败数 |
| Gauge | 当前瞬时值,例如队列长度、在线人数 |
| Timer | 次数 + 耗时分布,例如接口处理时长 |
现代 Spring Boot 的 metrics 和 traces 统一建立在 Micrometer Observation 体系上。业务代码可以直接注入 MeterRegistry,或使用 ObservationRegistry 让一次业务操作同时产生指标和 Trace 信息。
真正需要警惕的是标签基数。不要把下面这些原始值直接作为 Metrics tag:
- userId;
- orderId;
- 完整 URL;
- exception message;
- 任意自由文本。
这些值会产生近乎无穷的时间序列。低基数的 result=success|fail、reason=invalid_user|merchant_closed 才适合作为 Metric 标签。高基数信息应该放在日志或 Trace 属性里。
这和时序数据库的 high cardinality 问题本质完全一致:指标系统擅长聚合有限维度,不是拿来保存每个请求的完整明细。
Logging、Metrics、Tracing 是三个不同分辨率的观测工具
| 维度 | 日志 Logging | 指标 Metrics | 追踪 Tracing |
|---|---|---|---|
| 主要回答 | 这一次请求发生了什么 | 整体趋势什么时候异常 | 慢/错发生在调用链哪一段 |
| 数据形态 | 事件文本/结构化日志 | 时间序列数值 | Trace + Span |
| 数据量 | 大 | 小 | 中到大 |
| 典型用途 | 精确排错 | 告警、容量、趋势 | 跨服务链路定位 |
典型排障顺序是:
- Metrics 发现
order.failed突然抬高; - Trace 找到失败集中在 merchant-service;
- 用 TraceId 关联日志,看到某个请求的参数、返回码和异常栈。
异步线程和 MQ 会切断普通 ThreadLocal 上下文,接入追踪后要确认 Trace 上下文能够跨执行器和消息传播。否则“系统有 Tracing”并不等于真正能看到完整异步链路。
没有万能数据库,只有对数据结构和访问模式的匹配
Redis、MySQL、Elasticsearch、时序数据库经常被拿来比较“谁性能更高”。这种比较如果不带访问模式几乎没有意义。数据库的内部数据结构决定了它最擅长的问题。
Redis 和 MySQL:一个擅长按 Key 快速取值,一个擅长关系与索引查询
Redis 的优势是内存访问和简单数据结构操作,非常适合:
- 按明确 Key 读取热点数据;
- 计数、限流、集合、排行榜等原子数据结构操作;
- 作为 RDBMS 前的缓存层。
MySQL 的 B+Tree、事务和关系模型更适合:
- 主业务数据持久化;
- 唯一约束、事务一致性;
- 组合索引、范围查询和排序;
- 频繁 OLTP 更新。
不要因为 Redis 单 Key 读快,就拿它替代所有数据库查询;也不要为了找 item71* 这样的 Key,在业务链路里跑全 Key 搜索。
时序数据库和 MySQL:时间聚合是完全不同的工作负载
时序数据库围绕“时间 + 指标 + 标签”组织数据,天然适合:
- Metrics;
- 设备遥测;
- 按时间窗口 downsample;
- max/min/avg/rate 等聚合。
它不适合拿来保存具有复杂主键关系、频繁任意更新的 OLTP 主数据。
早期案例常使用 InfluxDB 1.x + InfluxQL;今天 InfluxDB 产品和客户端接口已经有较大演进,但这个设计结论没有变化:时序数据库的价值来自数据模型,而不是把 SQL 换成另一种语法。
尤其要控制 tag cardinality。如果把 URL、用户 ID 这类几乎无限取值的字段作为 tag,索引内存和 series 数量会迅速膨胀。
Elasticsearch 和 MySQL:全文搜索与事务更新不是同一个赛道
Elasticsearch/Lucene 的核心优势来自倒排索引。可以近似理解为:
1 | |
因此复杂全文搜索、相关性、分词查询明显更适合 ES,而不是 LIKE '%keyword%'。
反过来,ES 更新文档时需要维护索引,底层不是像 InnoDB 那样简单原地更新某个行字段。频繁变化的余额、库存、计数器不应该把 ES 当主写库。
典型组合是:
- MySQL 承担主写与一致性;
- ES 保存一份面向查询的扁平化文档;
- 通过 MQ/CDC 异步同步;
- 接受 ES 相比主库存在一定延迟。
复合存储架构的关键是“写入有主次,查询会路由”
当系统同时需要高并发主键查询、简单条件搜索、全文搜索和时间聚合时,一种合理架构是让不同存储承担不同职责:
flowchart LR
B[业务服务] --> W[同步写服务]
W --> MI[(MySQL 索引/关系表)]
W --> MS[(MySQL 分片主表)]
W --> O[(Outbox / MQ)]
O --> AW[异步写服务]
AW --> ES[(Elasticsearch)]
AW --> TS[(Time Series DB)]
AW --> C[(Redis Cache)]
Q[统一查询服务] --> C
Q --> MI
Q --> MS
Q --> ES
Q --> TS
B --> Q
这里最重要的不是画了多少数据库,而是几个边界非常清楚。
主数据只能有一个权威来源。 对订单、账务、库存等关键业务,RDBMS 通常是 system of record;Redis、ES、TSDB 是可重建的派生视图。
同步写负责用户刚提交后必须成立的事实。 如果 API 返回“订单创建成功”,主库数据必须已经可靠提交。
辅助存储允许异步延迟。 ES 索引、报表指标、缓存可以稍后更新,因此查询接口必须知道自己的时效性要求。
查询服务按需求路由,而不是所有查询都先打 MySQL。 例如:
| 查询需求 | 更合适的数据源 |
|---|---|
| 强一致主键详情 | MySQL 主数据 |
| 高频热点详情 | Redis,miss 回源 MySQL |
| 少量索引条件查 ID | MySQL 二级索引/索引表 |
| 多条件复杂检索、全文搜索 | Elasticsearch |
| 时间窗口聚合、趋势图 | 时序数据库 |
这种架构的代价也必须接受:数据复制增多、同步链路变长、最终一致性更复杂、排障需要 Trace 和数据版本信息。多存储不是性能优化按钮,而是用系统复杂度换取不同访问模式下的能力。
把这些原则连起来,才能形成真正的工程能力
前面的主题看起来从 Java 语言细节一路跨到了分布式存储,但它们之间有非常稳定的因果关系。
反射告诉我们:运行时代码结构可能和源码不同,所以动态框架必须校验元数据。Spring 告诉我们:容器和代理改变了对象真正的调用路径,所以 Bean 生命周期和切面顺序是业务语义的一部分。缓存和 MQ 告诉我们:一旦引入副本与异步,失败、重复和最终一致性就必须成为正常流程。Actuator、Metrics 和 Tracing 告诉我们:生产系统必须能够证明自己现在是否健康、哪里变慢、哪一步失败。多存储架构则把这些问题推到更大尺度:每个系统只做自己擅长的事,再通过可靠事件和统一查询把它们组合起来。
可以把一套 Java 业务系统的工程底线浓缩成下面这张表:
| 领域 | 上线前至少确认 |
|---|---|
| 反射/泛型 | 是否处理 bridge 方法、重载签名、继承扫描差异 |
| 注解 | 是 JDK 继承语义还是 Spring merged annotation 语义 |
| Bean | 是否无状态;Scope 是否和生命周期一致 |
| AOP/事务 | 调用是否经过代理;Advice 顺序;异常是否被提前吞掉 |
| 配置 | 最终配置来自哪个 PropertySource;是否存在环境变量覆盖 |
| 代码抽象 | 相同规则是否只有一处实现;新增类型是否需要修改主流程 |
| API | HTTP、业务码、data 状态是否语义唯一;版本策略是否统一 |
| 缓存 | TTL、雪崩、击穿、穿透、一致性和淘汰策略是否明确 |
| MQ | 生产失败、重复消费、补偿、DLQ、积压是否形成闭环 |
| 可观测性 | Liveness、Readiness、业务 Metrics、TraceId、告警是否可用 |
| 数据存储 | 主数据是谁;辅助存储能否重建;查询是否按访问模式路由 |
业务代码真正的技术含量,并不在于每个类都套一个设计模式,而在于能不能持续识别这些“隐藏在框架和基础设施下面的运行时边界”。当一个系统能做到规则只有一个真相、失败一定有闭环、状态可以被观察、数据职责清晰,即使业务本身仍然是大量 CRUD,它也已经不再是简单的 CRUD 系统了。