微服务文章很容易写成组件点名册:Spring、Redis、Kafka、MySQL,一个都不能少。真正决定系统质量的却不是“装了什么”,而是遇到超时、重复消息、缓存失效、从库延迟、服务重启和数据修复时,团队是否有一致的处理方式。

本文不试图给出唯一正确的架构,而是整理一套在中大型 Java 后端中比较耐用的技术栈、基础设施能力和工程原则。组件会更新,原则通常活得更久。

先说结论

  1. 数据库是事实源,缓存和搜索引擎通常只是投影。 投影允许短暂不一致,但必须能重建、能对账。
  2. 分布式系统默认会重复、乱序、延迟和部分失败。 幂等、超时、重试、补偿不能等事故发生后再补。
  3. 所有资源都必须有边界。 线程、队列、连接池、批次、响应体、重试次数和等待时间都不能无限。
  4. 基础设施的价值是提供一条铺好的路。 统一 Starter、BOM、日志、指标和安全默认值,比每个项目各写一套工具类可靠得多。
  5. 可运维性是功能的一部分。 一个任务如果不能暂停、重跑、审计和限速,就还没有真正完成。
  6. 先用简单方案,复杂度由证据驱动。 没有容量数据,不要急着分库分表;没有查询需求,不要先上 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 信息,再显式传入异步任务。

幂等不是一个注解就结束了

写接口通常需要业务幂等键。可靠的幂等可能由多层共同完成:

  1. 网关或接口层拦截短时间重复请求;
  2. 数据库唯一约束提供最终兜底;
  3. 状态机拒绝非法重复流转;
  4. 外部副作用保存请求号和执行结果;
  5. 消费者以事件 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 不只是“查不到就查库”:

  1. 读取本地缓存;
  2. 未命中时读取 Redis;
  3. 仍未命中时通过 single-flight 或短锁合并并发加载;
  4. 查询数据库;
  5. 写入带抖动的 TTL;空结果也短暂缓存;
  6. 数据更新成功后失效缓存,而不是先删缓存再写数据库。

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) 后只打印日志并正常结束。调度平台会认为执行成功,告警和重试都不会发生。

对账与自愈

跨数据库、缓存、搜索引擎、消息队列和外部服务的系统,最终一致性不是一句“过一会儿会好”。至少要有:

  1. 实时事件驱动同步;
  2. 短周期增量扫描补漏;
  3. 长周期全量或抽样对账;
  4. 可重复执行的修复任务;
  5. 修复前后的审计记录和差异指标。

事件解决时效,对账解决可信度,人工 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 列或长期文件,就已经成为数据契约。迁移时遵循:

  1. 新代码先兼容读取旧格式;
  2. 灰度开启新读取路径并监控 fallback;
  3. 再切换为写新格式;
  4. 等旧数据自然过期或完成回填;
  5. 最后删除旧格式兼容代码。

时间、金额、枚举和大整数要有明确表示。不要让某个库的默认行为决定长期数据格式。

文件与对象存储

大文件不要经过应用内存中转,优先由客户端使用短期凭证或预签名地址直传对象存储,应用只管理权限和元数据。上传对象必须先进入私有隔离 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

兼容迁移的一般顺序是:

  1. Expand:先增加新字段、表或索引,旧代码仍可运行;
  2. 双写或回填:小批量推进,记录进度与失败;
  3. 切读:灰度让新代码读取新结构;
  4. 停旧写:确认没有旧版本实例;
  5. 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。

技术选型会过时,这些工程习惯不会。