Skip to content

读懂一个系统的设计:先模型,再接口,最后看实现 ​

300 次 KafkaProducer.send() 在 8.2ms 内全部返回,这时一条消息都还没发出去;第一个回调在第 515ms 才到达,300 条消息只产生了一个网络请求。直接从 send() 的源码读起,很容易迷失在锁、内存池和重试里;先知道 Producer 的模型是「记录攒成批次、按分区发送」,这些现象和代码就都有了位置。

阅读 Spring、Kafka、JDK 这类成熟系统,是学习设计最有效的方式之一,但很多人读源码的方式是从入口方法一路 debug 下去。本文给出一个固定的阅读顺序,并用三个小切片(Spring 容器、Kafka Producer、JDK Stream)演示,每个切片都配一个可以直接运行的实验。实验环境:JDK 21.0.5、Spring Framework 7.0.9、kafka-clients 4.3.1(Broker 4.3.1)。

一、先说结论 ​

  • 阅读顺序是模型、接口、实现。之前学习的一些教程里把它概括成一句话:「了解设计,先模型,再接口,最后是实现。」模型决定了接口为什么长成这样,接口决定了实现要解决哪些问题。
  • 模型是系统提供的抽象:Spring 容器的模型是「Bean 定义和依赖关系」,Kafka Producer 的模型是「记录、批次、分区」,Stream 的模型是「惰性流水线」。
  • 接口不只是 Java interface:注解、配置项、命令行、HTTP API、DSL 都是接口,它们是用户接触模型的方式。
  • 带着问题读实现:先从模型和接口里提出问题(「send 返回时消息在哪里」),再去实现里找答案,而不是逐行通读。
  • 这个方法可以递归使用:实现里出现的组件,本身又有自己的模型、接口和实现。

二、为什么先看模型 ​

Kafka(系统)模型:主题、分区、偏移量接口:生产、消费、管理 API实现:Broker、副本、日志段Producer(子系统)模型:记录、批次、分区接口:send、flush、回调实现:累加器、发送线程RecordAccumulator(模块)模型:按分区组织的批次接口:append、ready、drain实现:内存池、批次队列实现层里出现的组件,成为下一轮阅读的对象每一层只回答四个问题:① 核心对象是什么 ② 对外提供哪些稳定的操作 ③ 哪一部分实现值得继续追 ④ 实现依赖哪些运行时或硬件假设带着问题读实现,才能分清哪些代码是设计的关键,哪些只是为了兼容、容错和性能而存在
图 1 · 同一组问题可以在系统、子系统、模块各层重复使用:先问它提供了什么抽象,再看对外怎么用,最后才带着问题看实现;每一层的「实现」又是下一层的入口

直接读实现的问题在于,实现里混着太多与设计核心无关的代码:兼容旧版本的分支、异常处理、性能优化、监控埋点。不知道模型的时候,没法判断哪些代码是关键。

先理解模型,相当于先拿到一张地图:

阅读层次要回答的问题典型材料
模型这个系统提供了什么抽象?核心对象有哪些,它们之间是什么关系?官方文档的概念章节、设计文档、论文
接口用户怎么使用这个抽象?有哪些稳定的操作和配置?API 文档、配置参考、示例代码
实现模型是怎么落地的?依赖了哪些运行时或硬件假设?源码、测试、提交记录

下面三个切片都按同样的四个问题展开:核心对象是什么、稳定的接口是什么、哪部分实现值得继续追、实现依赖哪些假设。

Spring 容器Kafka ProducerJDK StreamBean 定义与依赖图先定义,后实例化记录 → 批次 → 请求按分区攒批惰性流水线终止操作驱动执行registerBean、getBean后置处理器改定义send、flush、回调send 只是放进批次中间操作、终止操作有状态操作如 sorted定义 7 个,实例 0 个先创建被依赖的 Repo懒加载 Bean 按需创建300 次 send() 用 8.2ms 返回第一个回调在第 515ms 到达300 条合成 1 个生产请求组装后执行 0 次findFirst 只过滤了 7 个元素元素逐个流过整条流水线第一行:模型 第二行:接口 第三行:实测现象(Spring 7.0.9、kafka-clients 4.3.1、JDK 21.0.5)
图 2 · 三个切片的实验结果都指向同一个结论:接口的行为由模型决定。容器先有定义后有对象,send() 返回时消息还在批次里,Stream 在终止操作之前什么都不做

三、切片一:Spring 容器 ​

3.1 模型:Bean 定义和依赖关系 ​

Spring 容器的核心对象不是 Bean 实例,而是 Bean 定义(BeanDefinition):一个 Bean 用哪个类创建、作用域是什么、依赖哪些其他 Bean、是否懒加载。容器先收集所有定义,再根据依赖关系决定创建顺序。

这个模型带来了一种新的编程方式:对象不再由调用方 new 出来,而是由容器根据定义组装。依赖注入、AOP 代理、生命周期回调,都是在这个模型上叠加的能力。

3.2 接口:注册定义、获取 Bean、扩展点 ​

用户接触这个模型的方式有好几种:@Component 和 @Bean 注解、registerBean 这样的编程式 API、getBean 获取实例,以及修改定义的扩展点 BeanFactoryPostProcessor。

3.3 实验:先有定义,后有对象 ​

java
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.registerBean(Service.class);                          // 先登记 Service:它依赖 Repo
ctx.registerBean(Repo.class);
ctx.registerBean(Report.class, bd -> bd.setLazyInit(true));
ctx.addBeanFactoryPostProcessor(bf -> {
    // 此时只有定义,没有实例
    bf.getBeanDefinition(bf.getBeanNamesForType(Repo.class)[0]).setDescription("由后置处理器修改过");
});
ctx.refresh();

输出(节选):

text
refresh() 之前:还没有任何 Bean 实例
  BeanFactoryPostProcessor:已有 7 个 Bean 定义
    springSlice.Service   作用域=singleton 懒加载=false 已实例化=false
    springSlice.Repo      作用域=singleton 懒加载=false 已实例化=false
    springSlice.Report    作用域=singleton 懒加载=true  已实例化=false
    → 创建 Repo
    → 创建 Service,注入 Repo
refresh() 之后:Report 已实例化?false
    → 创建 Report(懒加载)

从输出能看出模型的三个特征:定义阶段一个实例都没有(7 个定义里有 4 个是容器自己的基础设施);Service 先登记,但容器按依赖关系先创建了 Repo;懒加载的 Report 直到第一次获取才创建。后置处理器修改的是定义,这是容器最重要的扩展点之一。

3.4 值得继续追的实现 ​

有了模型之后,实现里值得看的地方就很明确了:DefaultListableBeanFactory 怎样保存定义、preInstantiateSingletons 怎样按依赖创建单例、BeanPostProcessor 在哪个阶段介入。AOP 代理就是在这个阶段替换掉原始对象的,代理失效的各种情况见 Spring AOP 为什么会失效。

四、切片二:Kafka Producer ​

4.1 模型:记录、批次、分区 ​

Producer 的模型是:应用产生一条条记录,Producer 按目标分区把它们攒成批次,再把发往同一个 Broker 的批次合成一个网络请求。这个模型是为吞吐设计的:网络往返的成本被一批记录分摊。

4.2 接口:send、flush、回调 ​

对应的接口很少:send() 把记录交给 Producer 并立即返回一个 Future;回调在 Broker 确认后执行;flush() 等待已经交出的记录全部发送完成。配置项 linger.ms 和 batch.size 控制攒批的时间和大小。

模型决定了接口的语义:send() 返回,只代表记录进了批次,不代表发出去了。

4.3 实验:send 返回时,消息在哪里 ​

java
p.put("linger.ms", "500");                           // 最多等 500ms 攒批
for (int i = 0; i < 300; i++) {
    futures.add(producer.send(new ProducerRecord<>(topic, "k" + i, "v" + i), callback));
}
观察结果
300 次 send() 全部返回用时8.2 ms
返回时未完成的 Future300 个
第一个回调到达时间第 515 ms
这 300 条消息产生的生产请求1 个
send() 后立即 flush()7.5 ms 内完成

生产请求数来自 Producer 自带的指标 request-total。这组数字就是模型的直接体现:记录先进批次,批次等满 linger.ms 后才发出。

4.4 值得继续追的实现 ​

实现分成两部分:调用 send() 的业务线程只负责序列化、选择分区、追加到 RecordAccumulator 的批次里;后台的 Sender 线程负责把就绪的批次取出、按 Broker 合并、发送并处理响应。这个结构依赖的假设是:网络往返远比内存操作贵,所以值得用一点延迟换吞吐。

投递语义和重试见 Kafka 不丢、不重与 Exactly Once。

五、切片三:JDK Stream ​

5.1 模型:惰性流水线 ​

Stream 的模型是一条流水线:数据源、若干个中间操作、一个终止操作。中间操作只是描述「要做什么」,真正的执行由终止操作触发,元素一个一个地流过整条流水线。

5.2 接口:中间操作与终止操作 ​

filter、map 这类中间操作返回新的 Stream,findFirst、collect、forEach 这类终止操作产生结果。中间操作又分为无状态的(filter、map)和有状态的(sorted、distinct)。

5.3 实验:什么时候真正执行 ​

java
Stream<Integer> pipeline = IntStream.rangeClosed(1, 1_000_000).boxed()
        .filter(i -> { filtered.incrementAndGet(); return i % 7 == 0; })
        .map(i -> { mapped.incrementAndGet(); return i * 10; });
// 此时 filter 执行 0 次,map 执行 0 次
Optional<Integer> first = pipeline.findFirst();
// 结果 70,filter 执行 7 次,map 执行 1 次

一百万个元素的流水线,组装时一次都没执行,findFirst 只让 7 个元素经过了 filter。再用 peek 观察顺序:

text
  filter 看到 a
  filter 看到 bb
  map 看到    bb
  终止操作收到 BB
  filter 看到 ccc
  map 看到    ccc
  终止操作收到 CCC

元素是逐个流过整条流水线的,而不是先对所有元素 filter、再对所有元素 map。加上 sorted() 之后,顺序变成先收集全部元素、排序、再往下传。这就是有状态操作的含义。

5.4 值得继续追的实现 ​

ReferencePipeline 把每个操作包装成一个阶段,终止操作从最后一个阶段往前,把各阶段的 Sink 串成一条链,再遍历数据源。短路操作(findFirst、anyMatch)通过 Sink.cancellationRequested() 提前停止遍历。这个设计依赖的假设是:大多数操作可以逐元素完成,不需要中间集合。

六、把方法用到自己的系统上 ​

读懂别人的系统之后,可以用同样的问题检查自己的系统:

  1. 能不能用一两句话说出模块的模型? 说不出来,往往说明模块的职责不清楚。
  2. 接口的行为能不能从模型推出来? 如果调用方必须读实现才知道某个方法会不会阻塞、会不会重试,说明接口和模型脱节了。
  3. 实现里哪些部分是核心,哪些是适配? 核心部分应该稳定,适配部分可以随外部环境变化。
  4. 实现依赖了哪些假设? 比如「调用方都在同一个进程里」「时钟是单调的」。假设被打破时,系统会怎么表现。

七、常见误区 ​

  • 「读源码就是从入口一路 debug」:没有模型的指引,很容易在兼容和容错代码里迷路。
  • 「接口就是 Java interface」:注解、配置项、HTTP API、DSL 同样是接口,而且往往比 Java 接口更难修改。
  • 「理解了实现就理解了设计」:实现回答「怎么做」,模型回答「为什么这样做」。
  • 「成熟系统的每行代码都值得学」:很多代码是为了历史兼容或特定环境存在的,带着问题读,才能分清主次。
  • 「send() 返回就代表消息发出去了」:这是把接口的调用和模型的状态混在了一起。

小结 ​

阅读一个系统的设计,先问它提供了什么模型,再看用户通过哪些接口使用这个模型,最后带着具体的问题去看实现。三个切片的实验说明,接口的很多行为(懒加载、send() 立即返回、流水线不执行)都可以从模型直接推出。这个顺序同样适用于评审自己的系统:说不清模型的模块,接口往往也不稳定。

下一篇 封装、组合与多态 回到代码层面,看面向对象的三个手段各自控制哪一类变化。


配套实验

可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权