微服务文章很容易写成组件点名册:Spring、Redis、Kafka、MySQL,一个都不能少。真正决定系统质量的却不是“装了什么”,而是遇到超时、重复消息、缓存失效、从库延迟、服务重启和数据修复时,团队是否有一致的处理方式。
本文不试图给出唯一正确的架构,而是整理一套在中大型 Java 后端中比较耐用的技术栈、基础设施能力和工程原则。组件会更新,原则通常活得更久。
先说结论
- 数据库是事实源,缓存和搜索引擎通常只是投影。 投影允许短暂不一致,但必须能重建、能对账。
- 分布式系统默认会重复、乱序、延迟和部分失败。 幂等、超时、重试、补偿不能等事故发生后再补。
- 所有资源都必须有边界。 线程、队列、连接池、批次、响应体、重试次数和等待时间都不能无限。
- 基础设施的价值是提供一条铺好的路。 统一 Starter、BOM、日志、指标和安全默认值,比每个项目各写一套工具类可靠得多。
- 可运维性是功能的一部分。 一个任务如果不能暂停、重跑、审计和限速,就还没有真正完成。
- 先用简单方案,复杂度由证据驱动。 没有容量数据,不要急着分库分表;没有查询需求,不要先上 Elasticsearch。
技术栈全景
下面是一套常见组合。它更像菜单,不是要求所有服务全部引入。
| 领域 | 常用技术 | 主要职责 |
|---|---|---|
| 运行时 | Java LTS、Spring Boot 3、Spring MVC、Tomcat | Web 应用与依赖注入 |
| 构建 | Maven、BOM、Spring Boot Starter | 版本收敛、构建与打包 |
| API | Jakarta Validation、OpenAPI | 契约、校验与文档 |
| 服务调用 | OpenFeign、Apache Dubbo、Retrofit、OkHttp | 内部 RPC 与外部 HTTP |
| 配置与发现 | Apollo、Eureka,或云原生等价方案 | 动态配置、注册发现与路由 |
| 关系型存储 | MySQL、HikariCP、MyBatis、MyBatis-Plus | 事务数据与数据访问 |
| 数据拆分 | Apache ShardingSphere-JDBC | 分库分表、读写分离、多数据源 |
| 缓存 | Redis、JetCache、Caffeine | 分布式缓存与本地缓存 |
| 消息 | Kafka、RabbitMQ | 事件流、异步任务与延迟消息 |
| 搜索 | Elasticsearch | 复杂检索、聚合与多源列表 |
| 分析 | Databend、ClickHouse | OLAP 与离线分析 |
| 调度 | XXL-JOB | 分布式任务调度 |
| 实时通信 | WebSocket、Netty | 长连接与实时推送 |
| 稳定性 | Resilience4j | 限流、熔断、舱壁和降级 |
| 可观测性 | Micrometer、Prometheus、Grafana、JFR | 指标、告警与性能分析 |
| 测试 | JUnit 5、Mockito、AssertJ、Testcontainers | 单元、集成与基础设施测试 |
| 安全 | Spring Security、KMS/Vault、SAST/SCA | 鉴权、密钥与供应链安全 |
架构:先把依赖方向管住
服务边界比服务数量重要
微服务不是“把一个项目拆成很多仓库”。适合独立成服务的模块,通常至少有一种独立诉求:业务边界、容量模型、数据所有权、发布节奏、故障隔离或权限边界。否则,拆分只会增加网络调用、部署单元和排障成本。
即使已经是微服务,每个服务内部仍建议保持清晰的模块边界:
http-app / admin-app / worker-app
↓
core
↓
data
↓
api-contract / shared-model
api-contract只放稳定的接口与 DTO,不依赖应用实现。data负责实体、Repository 和 Mapper,不承载业务编排。core负责领域规则、状态机和事务边界。http-app、admin-app、worker-app是不同入口,只做协议适配与进程装配。- 依赖只能向下,底层模块不能反向依赖 Web 或 Consumer 模块。
这套结构的收益不是“看起来整齐”,而是同一份核心逻辑可以被 HTTP、消息消费和定时任务复用,同时避免 Controller、Listener 和 Job 各写一套业务规则。
Repository 是数据边界
Service 不应直接注入 Mapper。Repository 隔开业务语义与 SQL 细节:
Controller → Service → Repository → Mapper → Database
这样做可以集中处理读写路由、批量查询、乐观锁、缓存失效和数据转换。简单 CRUD 优先使用框架已有能力;只有复杂 JOIN、批处理或明确的性能优化才值得写自定义 SQL。
公共库不能成为杂物间
适合沉淀为公共能力的通常包括:
- 请求与链路上下文传递;
- 统一序列化、时间、HTTP 客户端和加密接口;
- 统一响应、错误码、日志脱敏和告警出口;
- 中间件自动配置、健康检查和指标;
- 经过验证的线程池、重试、幂等和限流实现。
不适合放进公共库的是业务状态、业务枚举和只被一个服务使用的“万能工具”。公共库一旦包含业务语义,升级就会从复用变成耦合。
Infra:提供默认正确的铺装道路
中大型团队通常会在 Spring Boot Starter 之上再做一层薄封装。好的 Infra 不应该隐藏一切,而应统一处理那些“每个服务都需要、每个服务都容易写错”的部分:
- 用 Parent POM 和 BOM 收敛依赖版本,避免传递依赖碰运气;
- 用 Starter 提供自动配置、默认超时、连接池、健康检查和指标;
- 为 Redis、数据库、HTTP、RPC、Kafka 等资源自动接入追踪和服务保护;
- 默认支持优雅停机、实例预热、配置刷新和灰度路由;
- 对危险配置进行校验,例如无限超时、无界队列和不安全的反序列化;
- 保留显式扩展点,业务可以覆盖 Bean 或配置,但必须能观察到覆盖结果。
好的封装让普通场景几乎零配置,同时让异常场景仍然可诊断。最糟糕的封装则只有一个巨大的 Starter,把几十个组件全塞进来,出了问题没人知道真正生效的是哪层配置。
一套成熟的 Infra 至少应该回答:默认值是什么、为什么这样设、如何覆盖、如何回滚、有哪些指标、升级是否兼容。
Java 与 Spring Boot
选择统一的 LTS 基线
Spring Boot 3 的最低基线是 Java 17,并全面迁移到 jakarta.* 命名空间。新项目可以选择更新的 Java LTS,但应先验证编译插件、字节码工具、Agent、序列化库和中间件客户端的兼容性。一个组织里同时漂着太多 JDK 版本,排障和升级都会变难。
实践上更重要的是:
- 编译版本、运行版本和容器基础镜像保持一致;
- 在 CI 中锁定 Maven 与 JDK 版本;
- 持续升级小版本安全补丁,不长期停留在“能跑就别动”;
- 升级 Spring Boot 时重点检查配置前缀、自动配置、Servlet 命名空间和废弃 API;
- 启动类只启用真正使用的能力,不堆满
@Enable...注解。
Controller 要薄
Controller 负责协议层工作:参数绑定、参数校验、认证信息读取、调用 Service 和映射响应。业务状态机、事务、重试和跨资源编排不应该藏在 Controller 中。
建议统一:
- 使用 Jakarta Validation 做类型、长度、范围和格式校验;
- 对枚举、排序字段、回调地址等采用白名单校验;
- 用全局异常处理器区分参数错误、业务错误和系统错误;
- 对外返回稳定错误码,不返回堆栈和内部异常信息;
- 请求与响应 DTO 独立于数据库 Entity;
- 分页必须限制
pageSize,批量接口必须限制元素数量; - 时间明确时区与精度,金额明确币种与舍入规则。
事务要短、要显式
@Transactional 应放在确实需要事务的方法上,不要粗暴加在整个类上。事务中避免远程调用、长循环、文件处理和不可控等待,因为数据库连接和锁会一直被占用。
事务代理还有一个常见陷阱:同类内部调用可能绕过 Spring AOP。遇到事务、缓存、重试、监控等注解,都应该确认调用是否真正经过代理,而不是“注解写了就算完成”。
API 与 RPC
契约优先
API 是跨团队、跨版本的长期契约。接口定义应该独立于实现,并有清晰的请求、响应、错误码和兼容规则。
相对安全的演进方式是给 DTO 增加可选字段。修改方法名、参数个数、字段类型、包路径或返回类型都可能破坏旧消费者;确实需要破坏性变更时,采用 V1/V2 双版本过渡,先升级消费者,再移除旧接口。
还要为未知枚举值留出余地。服务端新增枚举后,旧客户端不应该直接反序列化失败。
HTTP、Feign、Dubbo 怎么选
- HTTP + OpenFeign:生态成熟、可读性好,适合大多数内部同步调用。
- Apache Dubbo:适合 Java 体系内高吞吐、强接口契约的 RPC,但要承担协议、序列化和版本兼容成本。
- Retrofit/OkHttp:适合访问第三方 HTTP API,便于精细控制连接池、拦截器和序列化。
- 异步消息:适合不要求即时返回、需要削峰或需要多消费者订阅的场景。
不要因为“RPC 更快”就默认使用 RPC。多数业务瓶颈在数据库和下游,而不是 JSON 编解码。协议选择应由延迟预算、跨语言需求、治理能力和团队维护成本决定。
超时预算要逐层递减
入口请求如果只有 1 秒预算,下游调用不可能每个都配置 1 秒。应先预留本地处理和网络抖动,再给各下游分配 deadline:
入口总预算 > 聚合层预算 > 单个下游预算 > 下游自己的依赖预算
连接超时和读取超时需要分别配置,而且永远不要使用无限超时。重试会放大流量与尾延迟,只能用于幂等操作,并设置次数上限、总 deadline、指数退避和随机抖动。
跨线程执行时,不要继续读取已经结束的 Servlet 请求对象。应在主线程提取需要的用户、语言、灰度和 Trace 信息,再显式传入异步任务。
幂等不是一个注解就结束了
写接口通常需要业务幂等键。可靠的幂等可能由多层共同完成:
- 网关或接口层拦截短时间重复请求;
- 数据库唯一约束提供最终兜底;
- 状态机拒绝非法重复流转;
- 外部副作用保存请求号和执行结果;
- 消费者以事件 ID 或业务键去重。
缓存锁可以减少并发,但不能替代数据库约束和业务状态机。
压缩也有边界
JSON 响应较大时可以通过内容协商启用 GZIP 或 Zstandard,但不应压缩很小的响应。需要同时验证网关、客户端、HTTP/2 和多级代理,避免重复压缩或错误透传 Content-Encoding。
配置中心与服务发现
Apollo:配置与代码分离
Apollo 的价值不仅是“在线改配置”,而是统一环境隔离、审计、灰度和回滚。本地配置应尽量少,只保留应用身份、环境和启动必需项;数据库、Redis、消息队列等环境配置由配置中心管理,密钥则交给 KMS 或 Vault,而不是放进配置中心明文。
动态配置需要按危险程度分级:
- 文案、开关、阈值通常可以动态生效;
- 线程池、限流和批次大小需要边界校验;
- 数据源、序列化格式和路由规则应灰度验证;
- 需要重建客户端或连接池的配置,必须明确是否热更新;
- 多个相关开关要定义开启顺序与非法组合的保护逻辑。
配置监听器应记录“哪个键发生变化”,但不能把密钥或完整敏感值打进日志。
服务发现只是起点
无论使用 Eureka 还是 Kubernetes Service,成熟的服务发现还需要:
- 新实例预热,避免冷缓存节点瞬间接满流量;
- 优先同可用区调用,同时保留跨区降级能力;
- 用元数据支持版本和灰度路由;
- 下线时先从注册中心摘除,再停止接收新请求,最后等待存量请求结束;
- 健康检查区分“进程存活”和“可以接流量”。
灰度不只影响 HTTP。同步 RPC、Kafka、RabbitMQ、定时任务和缓存都可能跨越环境边界。只给入口打一个灰度标签,而下游不传播,最终得到的是一套无法解释的混合环境。
MySQL 与 MyBatis
MySQL 仍然是事务数据的首选
单表多少行必须分表,没有一个放之四海皆准的数字。行宽、索引数量、冷热比例、查询模式、硬件、备份恢复时间和 DDL 窗口都比“行数阈值”更重要。
当表进入亿级规模后,普通主键查询仍可能很快,但下面这些事情会明显变贵:
- 新增或重建大索引;
- 修改字段类型与字符集;
- 全表回填和归档;
- 大范围扫描、排序和深分页;
- 主从复制追赶与备份恢复。
因此,大表治理的重点是尽早设计索引、归档和在线变更方案,而不是等“超过某个数字”再分表。
数据访问的基本功
- 延迟敏感、调用量大的在线查询应有可用索引,并用执行计划验证,而不是凭感觉判断;小表、低选择性条件或有意的分析扫描可以例外;
- 避免循环查库形成 N+1,优先批量查询并在内存中组装;
- 只查询所需字段,不习惯性
SELECT *; - 大批量写入要分批,避免超大事务、SQL 包和 Binlog;
- 动态排序字段采用白名单映射,值参数使用预编译绑定;
- 金额使用
DECIMAL/BigDecimal或明确比例的整数,不使用浮点数; - 乐观锁适合低冲突更新,高冲突热点要重新设计写入模型;
- 唯一约束不仅是索引,也是并发正确性的最后防线。
MyBatis-Plus 很适合普通 CRUD,但 Wrapper 也可能生成糟糕 SQL。框架减少样板代码,不会替你理解索引、事务和锁。
读写分离
读写分离提高读能力,也引入复制延迟。刚写完立即读取、状态确认和幂等校验等场景需要读主库,普通列表和历史查询可以容忍从库延迟。
不要把“查不到”直接解释成“不存在”。在读从库的系统里,它也可能表示“还没复制过来”。读路由提示通常依赖线程上下文,用完必须清理,避免后续请求误读主库或误走分片。
分库分表是最后手段
ShardingSphere-JDBC 可以处理分片、读写分离和多数据源,但应用仍需承担分片约束。启用前先回答:
- 分片键是否出现在绝大多数核心查询中?
- 是否需要跨分片排序、分页、聚合或 JOIN?
- 扩容时如何迁移与校验数据?
- 全局唯一 ID 如何生成?
- 热点用户或热点租户是否会造成单分片倾斜?
- 后台查询和数据修复如何精确路由?
相同分片键的关联表可以设计为绑定表;小而稳定的字典数据可以广播。无法携带分片键的查询会扇出到所有节点,数量一多,性能和稳定性都会迅速恶化。
数据库连接池也不是越大越好。连接数最终受数据库 CPU、锁竞争和活跃查询限制。连接等待必须有短超时,连接生命周期应短于服务端或代理的回收周期,并监控等待时间、活跃连接和超时次数。
数据库与消息的一致性
“事务提交后直接发消息”存在进程崩溃窗口,“先发消息再提交”又可能发送不存在的数据。常见方案是事务 Outbox:业务写入与事件记录在同一个本地事务中,再由后台发布并标记状态。
没有 Outbox 时,也要至少具备失败补偿、定期扫描、幂等消费和对账。不要用一次 RPC 成功返回来代表整个跨系统流程已经完成。
Redis 与多级缓存
Redis 是性能层,不是默认事实源
Redis 很快,也可以存很多数据,但容量不是唯一约束。热 Key、Big Key、单线程命令耗时、网络带宽、重分片、持久化和故障切换都可能成为瓶颈。
典型的多级读取链路是:
Local Cache → Redis → Database / Downstream
适合缓存的是读取频繁、允许明确时间窗口内陈旧、可以重建的数据。强依赖数据库事务的数据不能因为“Redis 快”就搬去 Redis 做唯一存储。
Cache-Aside 的常见流程与一致性边界
常见的 Cache-Aside 不只是“查不到就查库”:
- 读取本地缓存;
- 未命中时读取 Redis;
- 仍未命中时通过 single-flight 或短锁合并并发加载;
- 查询数据库;
- 写入带抖动的 TTL;空结果也短暂缓存;
- 数据更新成功后失效缓存,而不是先删缓存再写数据库。
TTL 加随机抖动可以减少雪崩;空值缓存可以减少穿透;异步刷新与 stale-while-revalidate 可以在下游抖动时保留旧值。防穿透、防击穿、防雪崩是三个不同问题,不能只靠一个分布式锁解决。
这套流程仍然不是强一致协议。数据库提交后,缓存失效可能发送失败;并发 miss 也可能先读到旧数据,再在写方删除缓存之后把旧值回填。系统必须先定义可接受的陈旧窗口并用 TTL 兜底;要求更高时,应在事务提交后通过 Outbox 或 CDC 可靠发布失效事件,配合重试、监控,或用带版本的缓存值拒绝旧回填。需要强读后写的路径应绕过缓存或校验版本,不能只依赖 Cache-Aside。
本地缓存
Caffeine 或 JetCache 适合热点配置和高频只读数据。实践上应注意:
- 必须限制
maximumSize或权重,不能只配置过期时间; refreshAfterWrite应小于expireAfterAccess或expireAfterWrite;- refresh 通常由访问触发,不等于后台定时刷新;
- 异步刷新失败时,允许陈旧数据的场景可继续返回旧值;
- 集群节点间需要失效通知,或者接受每个节点最多陈旧一个 TTL;
- 监控命中率、加载耗时、加载失败和淘汰数量。
本地缓存越靠近代码,越容易快,也越容易出现节点间不一致。是否使用取决于延迟收益能否覆盖一致性复杂度。
Redis 工程细节
- 连接、命令和连接池等待都必须有有限超时;
- Key 带业务前缀和结构版本,避免不同模块碰撞;
- 批量操作优先 Pipeline,但集群中的多 Key 命令要考虑 Hash Slot;
- 禁止在线使用会全量扫描或长时间阻塞的命令;
- Big Key 要拆分,Hot Key 要复制、分片或加本地缓存;
- 序列化格式必须版本化,迁移时先兼容读,再切换写;
- 读写分离场景同样要考虑刚写后读的一致性。
分布式锁的边界
一个可靠的 Redis 锁至少要有唯一持有者 Token、原子解锁、租约超时和失败处理。业务执行时间可能超过租约时,还要考虑续租或 fencing token。
即便如此,分布式锁也不能证明业务只执行一次:GC 暂停、网络分区和锁过期都可能产生并发执行。数据库唯一约束、版本号和幂等状态机仍然需要存在。
Kafka 与 RabbitMQ
先按语义选,不按流行度选
Kafka 更适合高吞吐、可回放的事件流,同一 Key 可以保持分区内顺序;RabbitMQ 更适合灵活路由和工作队列,有界的重试延迟可以使用消息 TTL 配合死信交换机。延迟任务量不大时,Redis Sorted Set 也能实现,但必须补齐竞争领取、确认、重试、超时回收和监控,不能只写一个定时轮询。
| 需求 | 更常见的选择 |
|---|---|
| 大吞吐事件流、日志、CDC | Kafka |
| 多消费者独立订阅与回放 | Kafka |
| 灵活路由、工作队列 | RabbitMQ |
| 有界重试延迟(TTL + DLX)、死信路由 | RabbitMQ |
| 小规模、简单优先级或延迟队列 | Redis Sorted Set |
RabbitMQ 的 TTL 消息通常在到达队头后才过期或进入死信流程,不适合承诺精确调度;社区 delayed-message exchange 插件也已停止维护。长周期、大规模或必须精确恢复的定时投递,应使用持久化调度表或专门的调度系统。
默认按“至少一次”设计
不要假设消息只会被消费一次。消费者的一般流程是:
解析并校验 → 幂等检查 → 执行业务事务 → 记录处理结果 → 提交位点或确认消息
幂等键应来自稳定的事件 ID 或业务 ID,并有数据库唯一约束。只在 Redis 里写一个短 TTL 去重 Key,遇到数据恢复、长时间重放或 Redis 故障时可能失效。
消息处理还要覆盖:
- 重试采用退避和上限,区分可重试错误与永久错误;
- 毒消息进入死信队列,不能无限阻塞同一分区;
- 生产成功和消费成功都要有指标,不能只打印日志;
- 使用稳定业务键选择分区,明确真正需要的顺序范围;
- 批量消费要限制条数、字节数和单批处理时间;
- 监控 Consumer Lag、失败率、重试量和最老消息年龄;
- 消息体包含事件类型、事件 ID、发生时间、Schema 版本和 Trace ID;
- Schema 采用向后兼容演进,新增字段提供默认语义。
Spring Kafka 3 的异步发送返回 CompletableFuture,依赖旧 ListenableFuture 回调的代码通常会在迁移时暴露编译错误;错误处理器和重试配置的变化则可能编译通过但运行语义已经改变。升级时要分别检查这两类风险。
灰度与消息隔离
灰度版本如果生产和消费同一个 Topic,很容易由新代码写出旧代码读不懂的消息。可以使用独立 Topic、独立 Consumer Group,或在基础设施层按灰度标签路由。关键是生产者和消费者必须一起设计,切换顺序通常是先让消费者具备兼容能力,再放量生产者。
Elasticsearch
Elasticsearch 是查询投影
当多个关系表需要统一列表、全文检索、多条件过滤和聚合时,Elasticsearch 比在 MySQL 中拼大量 UNION 更合适。但它通常不应该成为交易事实源。
推荐链路是:
Database → CDC / Outbox / Event → Indexer → Elasticsearch
↘ Reconciliation / Rebuild
索引可以通过数据库或事件日志重建,才能放心处理误删、Mapping 变更和同步缺口。
Mapping 必须先设计
不要让生产数据决定动态 Mapping。尤其注意:
- 要求精确固定小数的金额优先按最小货币单位存为
long,并明确币种、比例和范围;只有允许按 scaling factor 舍入且验证过溢出边界的数据才使用scaled_float; - ID、枚举和精确匹配字段使用
keyword; - 全文字段使用
text,需要排序或聚合时增加 keyword 子字段; - 时间统一时区与格式;
- 对象数组明确使用
object还是nested; - 禁止无限制动态字段,避免 Mapping Explosion。
BigDecimal 序列化为 JSON 小数后,动态 Mapping 会把浮点数映射为 float,精度损失通常是静默发生的,不应等写入报错才发现。scaled_float 也会按 scaling factor 乘法并舍入到 long,超出范围时发生饱和。金额等精确数据必须预先设计 Mapping、比例与取值边界;修错通常需要新建索引和全量重建。
同步要处理乱序
同一文档的更新事件可能重复或乱序。索引文档应携带由权威源生成的聚合版本号、Outbox 序列或其他可比较的单调令牌,只接受更新版本。普通“更新时间”并不天然安全;若必须使用时间戳,就要保证单调性、足够精度,并提供相同时间下的稳定 tie-breaker,否则晚到的旧事件仍可能覆盖新数据。
索引升级优先使用“新索引 + 全量回填 + 增量追平 + Alias 原子切换”,不要在原索引上赌不可逆的 Mapping 修改。深分页使用 search_after 和稳定排序键,而不是无限增大 from + size。
最后一定要有对账指标:数据库记录数、索引记录数、同步延迟、失败事件和抽样字段一致性。
Databend 与 ClickHouse
两者都属于分析型数据库,但侧重点不同。
- Databend 采用存算分离,适合对象存储上的大规模分析、弹性计算和相对离线的查询。
- ClickHouse 擅长高吞吐写入、列式聚合和低延迟 OLAP,常用于日志、指标和实时分析。
选型时关注数据新鲜度、并发、更新模型、存储成本、查询延迟和运维能力。不要为了“以后可能分析”同时维护两套 OLAP。先明确谁是数据源、如何 CDC、Schema 如何演进、迟到数据如何修正、任务如何重跑。
分析库不应反向成为在线交易流程的强依赖。报表慢一点通常可以接受,核心写入被 OLAP 拖死则不行。
分布式任务调度
XXL-JOB 适合可运营任务
多副本服务里直接使用进程内 @Scheduled,每个实例都会执行一次。XXL-JOB 这类分布式调度平台提供动态参数、分片、失败重试、执行日志和人工触发,更适合分钟级同步、补偿、归档和对账任务。
一个生产级 Job 至少应该具备:
- 幂等:同一批数据重复执行结果不变;
- 可恢复:记录游标或检查点,从失败位置继续;
- 可分片:按稳定键拆分,避免重复和遗漏;
- 有边界:限制批次、并发、执行时间和影响行数;
- 可停止:有开关、截止时间和紧急终止手段;
- 可观测:记录扫描数、成功数、跳过数、失败数和耗时;
- 可审计:保留参数、操作者、版本和结果;
- 可演练:支持 dry-run,先评估影响范围。
秒级心跳或轻量扫描不一定适合经过完整的分布式调度链路,可以使用单独的轻量调度器配合 Leader Election 或短租约锁。更高频的数据处理通常应该改成事件驱动。
定时任务最危险的写法是 catch (Exception) 后只打印日志并正常结束。调度平台会认为执行成功,告警和重试都不会发生。
对账与自愈
跨数据库、缓存、搜索引擎、消息队列和外部服务的系统,最终一致性不是一句“过一会儿会好”。至少要有:
- 实时事件驱动同步;
- 短周期增量扫描补漏;
- 长周期全量或抽样对账;
- 可重复执行的修复任务;
- 修复前后的审计记录和差异指标。
事件解决时效,对账解决可信度,人工 Runbook 解决极端情况。
并发、线程池与虚拟线程
线程池必须隔离和有界
不同资源应使用不同的命名线程池:外部 HTTP、数据库并行查询、缓存刷新、图片处理和后台任务不要共用一个池。否则一个下游变慢就可能占满所有线程,拖垮无关功能。
普通线程池至少明确:
- 核心线程数、最大线程数和队列容量;
- 拒绝策略及其业务含义;
- 任务超时和取消;
- Trace、日志 MDC、认证与语言上下文的传递和清理;
- 活跃线程、队列深度、等待时间、拒绝次数和任务耗时指标;
- 服务停机时是否等待、取消或持久化未完成任务。
无界队列表面上“不丢任务”,实际上只是把过载从拒绝变成内存耗尽和超长等待。CallerRunsPolicy 也不是免费降级,它会把压力反推到请求线程。
并发度可以从 Little’s Law 得到一个起点:
并发数 ≈ 目标吞吐量 × 平均响应时间
最终仍需通过压测和下游容量调整。
CompletableFuture
通过 supplyAsync、runAsync 或其他 *Async 阶段调度业务任务时,应显式指定 Executor,避免所有业务挤进公共 ForkJoinPool;不调度异步工作的同步 continuation 不需要额外 Executor。并行任务要先确定语义:任一失败就失败、尽可能多返回、首个成功返回,还是允许默认值。无论哪种都要有统一 deadline,而不是每个 Future 单独等待一遍导致总超时叠加。
不要在同一个数据库事务里随意并行查询或写入。事务上下文和数据库连接通常绑定线程,换线程后语义可能已经改变。
虚拟线程(JDK 21+)不是无限资源
虚拟线程从 JDK 21 起成为正式特性,很适合大量阻塞式 I/O,例如 HTTP、RPC 和数据库读取;对 CPU 密集计算没有优势。在支持该能力的 Spring Boot 版本中,可以通过 spring.threads.virtual.enabled=true 使用 Boot 管理的执行器和调度器;直接使用 JDK API 时则要自行管理 Executor 生命周期。它降低的是线程成本,不会增加数据库连接、下游 QPS、文件描述符和网络带宽。
因此,即使使用虚拟线程,也需要通过连接池、Semaphore、Bulkhead 或并发上限保护下游。还要确认 ThreadLocal 上下文传播、监控工具和依赖库兼容性。虚拟线程是 daemon thread;如果定时或后台进程可能只剩虚拟线程,需要设置 spring.main.keep-alive=true 或保留等价的非 daemon 生命周期。定时任务、消息消费和长期后台计算已有自己的调度模型,不必为了追新而强行换成虚拟线程。
WebSocket 与实时推送
WebSocket 的难点不在握手,而在连接生命周期和消息一致性。
客户端需要心跳、指数退避重连、随机抖动、订阅恢复和鉴权刷新;服务端需要连接上限、空闲回收、慢消费者隔离、发送队列上限和优雅停机。
实时行情或状态推送可以采用:
Upstream WebSocket
↓
Leader / Ingestion Worker
↓
Message Bus or Pub/Sub
↓
WebSocket Gateway Cluster
↓
Clients
如果多个实例都直连上游,会重复消耗连接和流量。可以通过 Leader Election 或租约锁只保留一个活跃采集者,并让其余实例随时能够接管。
消息协议最好提供 Snapshot、递增 Sequence 和增量事件。客户端发现 Sequence 缺口时主动丢弃本地状态并重新拉取 Snapshot。WebSocket 只负责低延迟传输,不应被当成可靠消息队列。
外部服务集成
第三方 API 最好收敛到 Adapter 层,将外部字段、状态和错误转换为内部统一模型。不要让供应商 DTO、签名算法和状态码扩散到核心业务。
每个外部依赖都应该有一张“失败说明书”:
- 连接、读取和总 deadline;
- 哪些错误允许重试,哪些必须立即失败;
- 限流规则与本地并发上限;
- 幂等键、请求签名和回调验签;
- 主备数据源或安全降级;
- 请求与响应日志的脱敏规则;
- 数据延迟、异常值和部分字段缺失的处理;
- 定期对账与人工修复入口。
外部接口返回成功,只能证明“对方当时这样回答了”,不能替代本地状态机和后续核验。涉及资金、库存、权益或不可逆副作用时尤其如此。
稳定性治理
保护链
常见的入口保护顺序是:
流量清洗 → 限制并发 → 熔断 → 限流 → 业务资源
具体顺序可以调整,但每一层的目标不同:
- 限流控制进入速率;
- 舱壁限制同时占用的资源;
- 熔断在下游持续失败时快速失败;
- 超时限制单次调用成本;
- 降级提供可接受的替代结果;
- 重试只处理短暂且可安全重放的失败。
降级必须语义安全。返回旧配置、空推荐列表可能没问题,把未知支付状态当成失败或成功都可能造成更大事故。
保护规则最好动态配置,但必须有上下限和默认值。限流阈值不应拍脑袋,应基于压测、线程池、连接池、下游容量和延迟目标共同确定。
优雅启动与停机
启动顺序通常是:加载关键配置与缓存、建立连接、完成自检、注册服务、逐步放量。停机则相反:摘除流量、停止领取新消息或任务、等待在途工作、提交必要状态、关闭连接。
健康检查应区分:
- Liveness:进程是否需要重启;
- Readiness:当前是否可以接收流量;
- 业务健康:关键链路是否达到服务目标。
不要因为一个非关键下游短暂失败就让 Liveness 失败,造成所有实例反复重启。
可观测性
先覆盖四个黄金信号
- 流量:QPS、消息速率、任务扫描量;
- 延迟:P50、P95、P99 和最大值;
- 错误:错误率、超时、重试、死信和业务拒绝;
- 饱和度:CPU、内存、GC、线程池、连接池、队列和 Consumer Lag。
Micrometer 可以把应用指标接入 Prometheus,再由 Grafana 展示与告警。框架自带指标只能覆盖资源状态,关键业务状态仍需业务埋点,例如待处理记录年龄、状态卡住数量、缓存回源量和对账差异。
指标标签要克制
不要把用户 ID、订单 ID、完整 URL 或异常消息当成标签。高基数标签会让时序数据库先于业务系统崩溃。适合做标签的是数量有限、稳定的维度,例如结果、渠道类别、错误类型和版本。
日志与追踪
结构化日志至少包含时间、级别、服务、实例、Trace ID、操作、结果和耗时。跨 HTTP、RPC、消息和异步线程传播 Trace 时,任务结束必须清理 ThreadLocal/MDC,避免串号。
日志用于解释单次事件,指标用于发现趋势,Trace 用于还原调用链。三者不能互相替代。性能疑难问题可以使用 JFR 做低开销采样,避免在生产环境临时加入大量 Debug 日志。
告警必须指向行动:影响是什么、阈值是什么、看哪个面板、按哪个 Runbook 处理。没有处置方式的告警最终只会被静音。
序列化与数据格式
JSON 建议统一使用 Jackson 或另一套经过安全评估的序列化方案。不要让各模块分别使用不同默认配置,也不要依赖带任意类型信息的多态反序列化。
序列化格式一旦进入 Redis、Kafka、数据库 JSON 列或长期文件,就已经成为数据契约。迁移时遵循:
- 新代码先兼容读取旧格式;
- 灰度开启新读取路径并监控 fallback;
- 再切换为写新格式;
- 等旧数据自然过期或完成回填;
- 最后删除旧格式兼容代码。
时间、金额、枚举和大整数要有明确表示。不要让某个库的默认行为决定长期数据格式。
文件与对象存储
大文件不要经过应用内存中转,优先由客户端使用短期凭证或预签名地址直传对象存储,应用只管理权限和元数据。上传对象必须先进入私有隔离 bucket 或 prefix,并标记为待扫描;在类型校验、病毒扫描等检查全部通过前,不生成可下载地址,也不允许 CDN 或公开域名访问。检查成功后再原子更新状态并移动或复制到可发布区域,失败则继续隔离或删除并保留审计记录。
文件服务需要:
- 限制大小、数量和上传速率;
- 校验 Magic Bytes,而不是只看扩展名;
- 使用随机文件名,隔离租户目录并禁止执行权限;
- 对图片、压缩包和文档防范解析炸弹;
- 异步执行病毒扫描、转码和缩略图;
- 回调签名校验且幂等;
- 配置生命周期、CDN 缓存和删除审计。
SVG、HTML 等主动内容不应直接在可信域名下以内联方式展示。
安全基线
安全不应靠业务开发“记得做”,而应尽量成为 Starter、网关、CI 和代码审查的默认能力。
- 所有外部输入在服务端验证,优先白名单;
- SQL 值参数必须绑定,动态表名和排序列必须经过白名单映射;
- 认证之后还要校验资源所有权,防止水平越权;
- 服务间通信使用 TLS,并根据风险选择 mTLS 或请求签名;
- 密钥来自 KMS/Vault,禁止写入代码、镜像、日志和普通配置;
- 密码使用 Argon2 或 BCrypt,不使用可逆加密;
- 对称加密使用带认证的模式和随机 IV,例如 AES-GCM;
- Token 使用
SecureRandom,不能用普通伪随机数; - 日志不记录密码、密钥、Token、完整证件或支付信息;
- 错误响应不暴露堆栈、SQL 和内部地址;
- 生产环境限制 Actuator、调试接口和管理端点的暴露;
- 依赖持续进行 SCA,代码进行 SAST,镜像进行漏洞扫描;
- 文件上传做类型、大小、路径和内容检查。
安全场景中的随机数和普通业务 ID 的随机数要求不同。为了性能优化 UUID 生成器,不代表它可以拿来生成 Token。
测试与构建
测试金字塔要验证真实边界
- 单元测试:覆盖状态机、计算、边界条件和异常分支;
- Repository 集成测试:验证 SQL、索引假设、类型映射和事务;
- Controller 集成测试:验证校验、序列化、异常映射和安全过滤器;
- 契约测试:验证 RPC、消息 Schema 和兼容性;
- 端到端测试:只保留关键用户路径,避免数量失控。
JUnit 5、AssertJ 和 Mockito 足以覆盖大多数单元测试。涉及 MySQL、Redis、Kafka、Elasticsearch 等真实语义时,优先使用 Testcontainers;H2 适合简单快速测试,但不能证明方言、锁、索引和 JSON 行为与 MySQL 一致。
测试必须自己准备数据、自己清理数据,不依赖共享环境里“刚好存在”的记录。外部 API 测试与普通单元测试分组,默认构建不应意外访问真实外部系统。
Maven 治理
- Parent POM 与 BOM 统一版本,子模块不随意覆盖;
- 直接使用的依赖要显式声明,不依赖偶然的传递依赖;
- API 模块保持轻量,运行时依赖不要泄漏给所有消费者;
- 用 Enforcer 或依赖分析发现版本冲突和未声明依赖;
- 构建产物写入 Git Commit、版本和构建时间,便于追溯;
- CI 中做格式检查,但不要在构建阶段静默重写源码;
- 先运行最小相关模块,再运行依赖模块和完整回归;
- 构建日志必须保留失败摘要,不能为了“清爽”隐藏错误。
测试优先不是追求覆盖率数字。更有价值的是先写一个能重现缺陷或表达需求的失败测试,再用最小修改让它通过。
发布、变更与 Runbook
数据库变更采用 Expand and Contract
兼容迁移的一般顺序是:
- Expand:先增加新字段、表或索引,旧代码仍可运行;
- 双写或回填:小批量推进,记录进度与失败;
- 切读:灰度让新代码读取新结构;
- 停旧写:确认没有旧版本实例;
- Contract:最后再移除旧字段和兼容代码。
大表 DDL 必须评估锁表、复制延迟、磁盘空间、执行窗口和回滚方式。回滚代码不一定能回滚数据,所以数据库变更应默认向前兼容。
灰度发布是整条链路的事情
灰度检查至少包括:HTTP/RPC 路由、消息生产与消费、缓存 Key、数据库 Schema、配置开关和后台任务。新版本先做到“能读旧、能写兼容”,再逐步放量。
新 API、数据库迁移、外部集成、缓存层变化和多服务协同配置,应该有 Runbook。Runbook 至少说明:
- 变更目标与影响范围;
- 前置条件与配置;
- 执行顺序与负责人;
- 验证查询与监控面板;
- 回滚条件和回滚步骤;
- 数据补偿、重放或重建办法。
修复能力要预先设计
生产系统迟早需要补数据。好的修复工具支持精确选择范围、dry-run、限速、暂停、恢复、幂等、审计和结果导出。临时 SQL 或一次性脚本如果无法复核影响范围,往往会制造第二次事故。
常见反模式
- 给所有调用统一配置一个很大的超时;
- 在重试之前没有证明操作幂等;
- 用分布式锁代替数据库唯一约束;
- 把 Redis 或 Elasticsearch 当作无法重建的事实源;
- 使用无界线程池、无界队列或无限批次;
- 在事务中调用慢外部服务;
- 只写实时同步,没有对账和重建;
- 多副本服务直接运行进程内定时任务;
- 日志里打印完整请求、响应和敏感信息;
- 指标标签携带用户 ID、请求 ID 等高基数字段;
- 只监控机器 CPU,不监控业务积压和数据差异;
- 动态开关没有默认值、边界、灰度和回滚;
- 破坏性修改 RPC/消息 Schema,要求所有消费者同时上线;
- 为了追求“统一”把所有组件都塞进每一个服务。
上线前检查清单
接口与调用
- 输入、分页、批量大小和动态字段均有边界校验
- 错误码稳定,异常响应不泄露内部信息
- 所有外部调用都有连接、读取和总 deadline
- 重试操作具备幂等性,并有次数上限和退避
- RPC、DTO 和消息变更保持向后兼容
数据与缓存
- 核心查询有索引并检查过执行计划
- 没有 N+1、意外的全表扫描和无边界批处理
- 事务范围明确,事务中没有慢远程调用
- 缓存有容量、TTL、穿透与击穿保护
- 数据库、缓存、搜索索引之间可以对账和重建
- 分片查询携带分片键,刚写后读场景明确读主库
消息与任务
- 消费者支持重复、乱序和毒消息
- 生产失败与消费失败都有告警和补偿
- Consumer Lag、重试和死信有监控
- Job 可幂等、限速、暂停、恢复和审计
- 灰度环境不会误消费正式消息
稳定性与安全
- 线程池、连接池、队列和并发均有上限
- 限流、熔断、舱壁和降级经过压测或演练
- 启动预热、Readiness 和优雅停机工作正常
- Trace 能跨 RPC、消息和异步线程传播且不会串号
- 日志、指标和告警足以定位故障
- 密钥、日志、上传、管理端点和依赖漏洞通过安全检查
发布与恢复
- 数据库和配置变更向前兼容并可灰度
- 发布后有明确验证项和停止条件
- 回滚、消息重放、索引重建和数据修复路径可执行
- 高风险变更有 Runbook,且执行人理解步骤
最后
成熟的后端技术栈不是组件越多越好,而是每个组件都有清晰边界、可靠默认值和失败后的恢复路径。
Spring Boot 解决应用装配,MySQL 保存事实,Redis 加速读取,Kafka 与 RabbitMQ 解耦流程,Elasticsearch 服务复杂查询,XXL-JOB 负责可运营任务,Resilience4j 控制故障扩散,Micrometer 和追踪系统让问题可见。真正把它们连成系统的,是幂等、超时、状态机、对账、灰度和 Runbook。
技术选型会过时,这些工程习惯不会。