abstraction - interface - layering

02. 抽象、接口与分层

0. 本章先解决什么问题

计算机系统太复杂了。一个程序运行时,下面有语言运行时、操作系统、文件系统、网络协议、CPU、内存、磁盘、网卡。没有人能在写每一行代码时同时想着所有细节。

人类能使用复杂系统,靠的是抽象。

但是抽象也会骗人:它让你看起来像是在“写文件”“发请求”“访问数组”,实际底层可能发生了缓存、复制、阻塞、重传、权限检查、状态切换。

本章要建立一个能力:

既能使用抽象,又知道抽象下面藏了什么边界和成本。

先看抽象的边界:调用者应该依赖接口契约,而不是被实现细节牵着走。

抽象、接口、分层与泄漏

这张图怎么读

图里的重点是“上层模型”和“下层现实”之间有一道接口边界:

  • 调用者看到:简单模型、函数、协议、路径、对象
  • 接口承诺:能做什么、失败怎么表示、状态如何变化
  • 底层现实:buffer、锁、队列、缓存、系统调用、硬件、网络

抽象最容易让人犯两个相反的错误:

错误表现后果
只看上层以为 send() 返回就等于对方收到忽略缓冲区、重传、超时、半关闭
只看底层每次写业务代码都陷进实现细节代码和具体实现强耦合,换底层就崩

成熟的做法是:平时依赖接口契约;出问题、做性能判断、设计边界时,知道该往下看哪一层。

1. 抽象是什么

抽象是:隐藏复杂细节,只暴露可使用的模型。

抽象你看到的它隐藏的
变量名字和值内存地址、位模式、类型解释
数组下标访问地址计算、连续内存、边界检查
文件路径和内容磁盘块、页缓存、权限、元数据
进程一个运行中的程序CPU 调度、虚拟内存、文件描述符
Socket读写字节网卡、协议栈、路由、缓冲区、重传
HTTP请求和响应TCP 字节流、连接复用、分片、TLS

抽象让复杂系统可用。你不需要懂磁盘扇区,也能读文件;不需要手动控制网卡,也能发网络请求。

1.1 抽象不是简化事实,而是选择暴露什么

一个好抽象不是把底层事实抹掉,而是把“当前层应该关心的事实”暴露出来,把“不该耦合的细节”藏起来。

例如文件抽象暴露:

路径
读写
权限
当前位置
错误码

它隐藏:

磁盘块布局
缓存页
inode 结构
块设备调度
控制器细节

但注意,隐藏不等于不存在。磁盘满、权限不足、文件被删除、写入未落盘,这些底层现实仍然必须通过接口以某种方式冒出来。否则抽象就不是“简化”,而是“撒谎”。

所以判断一个抽象的质量,可以看它是否做到:

标准好抽象坏抽象
隐藏细节不要求调用者理解内部结构调用者必须知道内部字段怎么拼
暴露必要失败能区分权限、容量、网络、超时等关键失败所有失败都只返回 false
保留正确心智模型调用者能预测状态变化调用一次偷偷改很多全局状态
允许替换实现底层换了,上层基本不变上层绑定具体类、表结构、缓存布局

2. 抽象的好处

2.1 降低认知负担

如果没有文件抽象,你要读一段内容,可能需要关心:

  • 文件在磁盘哪些块上。
  • 每个块是否连续。
  • 块设备怎么寻址。
  • 是否命中缓存。
  • 权限怎么检查。
  • 读失败怎么重试。

有了文件抽象,你只需要:

open -> read -> close

2.2 让系统可组合

一个应用可以通过文件接口读文本,通过 Socket 接口发网络,通过进程接口启动子任务。

每个模块只需要遵守接口,不需要知道彼此内部怎么实现。

2.3 允许底层替换

只要接口不变,底层可以变化:

抽象底层可以换成
文件HDD、SSD、网络文件系统、内存文件系统
网络连接以太网、Wi-Fi、光纤、蜂窝网络
数组容器不同扩容策略、不同内存分配器
排序接口快排、归并、堆排、混合排序

这就是抽象的力量。

2.4 抽象让推理有边界

抽象不只是为了少写代码,更重要的是让推理边界清楚。

如果你调用一个排序函数:

sort(list)

你理想上只需要知道:

  • 输入:可比较元素序列
  • 输出:按顺序排列的同一批元素
  • 保证:不丢元素、不重复元素、顺序满足比较规则
  • 复杂度:大概 O(n log n) 或文档承诺的范围
  • 副作用:是否原地修改,是否稳定排序

你不应该每次都读一遍快排、归并、堆排实现。接口契约把“我能信什么”写清楚,调用者才能局部推理。

四大件里这种边界随处可见:

课程抽象边界
组成原理指令集隐藏微架构,寄存器/内存模型暴露给程序
操作系统系统调用隐藏设备和调度,暴露进程、文件、内存接口
计算机网络TCP 隐藏丢包重传,暴露可靠字节流
数据结构容器隐藏存储策略,暴露插入、删除、查找语义

你要学会问:这一层到底承诺了什么?没有承诺什么?

3. 抽象不是魔法

抽象隐藏细节,但细节仍然存在。

看起来底层事实
写文件完成了可能只是写进页缓存,还没真正落盘
发送数据完成了可能只是写进内核缓冲区,还没到对方
数组访问很快依赖连续内存和地址计算
浮点数像小数实际是二进制近似,有精度边界
网络连接已建立仍可能丢包、重传、超时、半关闭
删除文件了可能只是目录引用删了,空间稍后回收

这叫抽象泄漏:上层看似不用关心底层,但底层限制会在某些场景冒出来。

3.1 抽象泄漏的四种常见形式

抽象泄漏不是一句玄学,它通常有具体形态:

泄漏类型现象底层原因
成本泄漏同样 API,有时快有时慢cache miss、IO、重传、扩容、锁竞争
失败泄漏上层操作出现底层错误磁盘满、连接断开、权限不足、编码不兼容
语义泄漏上层模型和底层语义不完全一致write 成功不等于持久化,send 成功不等于送达
时序泄漏调用顺序、并发、重试影响结果race condition、乱序包、重复回调

例子:map.get(key) 看起来是 O(1),但在极端情况下可能变慢:

hash 计算成本高
hash 冲突多
触发扩容 rehash
数据不在 CPU cache
并发 map 需要锁

这不是哈希表“坏了”,而是上层 O(1) 抽象没有承诺每一次操作都同样快。

再看 HTTP:

GET /api/data

看起来是一条请求,底层可能包含:

DNS -> TCP -> TLS -> HTTP -> 服务端排队 -> 数据库 -> 响应传输

一个 timeout 可能来自任何一层。只有知道抽象会怎么泄漏,才能定位真正问题。

4. 接口是什么

接口是上层使用下层的约定。

它规定:

  • 可以做什么操作。
  • 输入是什么。
  • 输出是什么。
  • 错误怎么表示。
  • 操作前后状态如何变化。
  • 调用者和被调用者各自负责什么。

比如一个队列接口可以这样描述:

操作输入输出状态变化
push(x)元素 x无或成功标记x 进入队尾
pop()队头元素队头元素离开
is_empty()布尔值不改变队列

接口不仅是函数名。真正重要的是行为契约。

4.1 接口契约要写到状态和责任

函数签名只能告诉你“形状”,契约才告诉你“能不能安全使用”。

例如:

read(buffer) -> n

这个签名远远不够。真正需要知道:

契约问题为什么重要
n = 0 表示什么EOF、暂时没有数据、还是读取了 0 字节?
是否可能部分读取网络和文件读取都可能只返回部分数据
失败如何表示异常、错误码、特殊返回值?
buffer 谁拥有函数会不会保存引用,调用后能不能复用?
是否阻塞可能挂住线程多久?能不能取消?
是否线程安全多线程同时读会怎样?
调用后状态如何变化文件偏移、socket 缓冲区、内部 cursor 是否推进?

接口最怕“看起来能用,但关键语义靠猜”。靠猜的接口一定会在边界场景爆炸。

反例:save() 返回成功,但调用者理解错了成功层级

假设一个接口:

save(data) -> success

调用者可能以为:

success = 数据已经持久化,崩溃后也不会丢

实现者可能只是做了:

success = 数据写入本地内存队列

这就是接口契约错位。两边都“没有写错代码”,但系统语义已经不可靠。

更清楚的接口应该拆出成功层级:

接口/状态含义
enqueueSave(data)已放入待写队列
flush()已尝试推到下层存储
commitDurably()已越过持久化边界
getStatus(id)查询当前保存状态

或者至少在文档中写明:

save 返回只代表提交成功,不代表持久化完成。
如果需要崩溃后不丢,必须调用 durable commit。

好接口不是名字短,而是让调用者不会把一个层级的成功误读成另一个层级的成功。

4.2 所有权是接口设计的核心

很多 bug 本质是所有权不清楚:

这块内存谁负责释放?
这个连接谁负责关闭?
这个对象谁可以修改?
这个 buffer 调用后还能不能继续用?
这个缓存值是不是共享引用?

所有权可以分几类:

所有权模式含义风险
调用者拥有被调用者只临时读取被调用者偷偷保存引用会出问题
被调用者接管调用后调用者不再使用调用者继续修改会破坏状态
共享只读多方可读,不可改需要防止隐式可变对象
共享可写多方可改需要锁、事务、版本号或不可变快照

即使语言带自动内存管理,所有权仍然存在。比如把内部可变列表直接返回给外部,外部就可能修改内部状态。你以为暴露的是查询接口,实际上暴露了可变结构。

getItems() 返回内部可变列表引用
-> 调用者 clear()
-> 模块内部列表被清空

更稳妥的接口会返回不可变视图、拷贝,或者提供受控操作。

5. 好接口和坏接口

好接口应该清楚回答:

  1. 谁拥有数据?
  2. 谁可以修改状态?
  3. 出错时怎么表示?
  4. 是否阻塞?
  5. 是否线程安全?
  6. 调用顺序有没有要求?
  7. 资源是否需要释放?

坏接口常见问题:

坏味道后果
错误语义模糊调用者不知道怎么处理失败
返回值含义混乱不知道是空、失败还是未初始化
隐式修改太多状态调用一次影响很多地方
资源释放责任不清泄漏文件、连接、内存
调用顺序没写清楚状态机被误用

很多系统 bug 不是算法错,而是接口契约不清楚。

5.1 错误语义决定调用者能不能恢复

接口不是只在成功时有用。失败时的信息质量,决定系统能不能恢复。

比如保存文件失败:

返回方式调用者能做什么
false只能提示“失败了”,不知道原因
SAVE_FAILED仍然太粗,不知道能不能重试
PERMISSION_DENIED提示用户换路径或授权
NO_SPACE_LEFT提示清理空间
TEMPORARY_IO_ERROR可以重试
DATA_INVALID不该重试,应修正输入

同一个“失败”,恢复策略完全不同:

  • 可重试失败:网络抖动、临时锁竞争、服务暂时不可用
  • 不可重试失败:参数非法、权限不足、格式错误
  • 需要人工处理:数据冲突、容量不足、配额超限

如果接口把它们全压成一个布尔值,上层只能猜,猜错就会出现重复提交、无限重试、吞掉错误、状态不一致。

机制深挖:接口契约至少要覆盖六条边界

一个接口如果只写输入和输出,很容易在真实系统里不够用。更完整的契约应该覆盖六条边界:

边界要回答的问题漏掉后的典型故障
ownership输入对象、返回对象、资源句柄归谁管理外部修改内部状态、资源泄漏、重复释放
lifetime返回值、buffer、连接、订阅能活多久使用过期引用、连接被回收后继续复用
state transition调用前后状态如何变化重复调用、乱序调用破坏状态机
completion level返回成功代表哪一层完成把入队成功误解成持久化成功
error taxonomy失败能否重试、能否恢复、是否需要人工介入无限重试、吞掉不可恢复错误
idempotency同一意图重复调用会不会产生重复副作用超时重试后重复创建、重复扣减、重复通知

可以用这张简表检查任何接口:

谁拥有资源?
什么时候算完成?
失败后能不能重试?
重复调用是否安全?
调用顺序有没有限制?
返回值是否暴露内部可变状态?

如果这些问题必须靠读源码猜,接口就还没有真正设计完。

5.2 同步、异步、阻塞也属于接口契约

很多接口 bug 来自调用者不知道“什么时候真的完成”。

语义含义
同步完成函数返回时结果已经完成
异步提交函数返回只代表任务已提交,结果以后才有
阻塞等待调用线程可能被挂起
非阻塞尝试立即返回,可能表示暂时没有结果
可取消调用者能中止未完成操作
不可取消一旦提交只能等完成或失败

例如“发送消息”可以有多个层次:

放入本地队列成功
写入内核 socket buffer 成功
对端 TCP ACK 成功
对端应用读取成功
对端业务处理成功

如果接口只叫 send(),却不说明“成功”是哪一层成功,调用者就会误用。

6. 协议也是接口

接口通常发生在模块之间。协议发生在多个参与者之间。

协议约定什么
HTTP请求/响应格式、方法、状态码、头部
TCP连接状态、序号、确认、重传、窗口
文件格式字节顺序、字段长度、元数据位置
指令集指令编码、寄存器、寻址方式
字符编码字符和数字如何映射

协议比普通接口更强调顺序和状态。

比如简化版请求协议:

代码块PLAINTEXT · 5 行收起展开
client: HELLO
server: OK
client: DATA length=5
client: abcde
server: DONE

如果双方对顺序理解不同,即使每个字节都传到了,也无法正确通信。

6.1 协议接口更怕版本和兼容性

普通函数接口通常在同一个代码库里升级,协议接口常常跨进程、跨机器、跨版本。于是多了一个问题:

新客户端会不会遇到旧服务端?
旧客户端会不会遇到新服务端?
字段新增后旧解析器会不会崩?
错误码变化后旧调用者会不会误判?

一个协议字段看似小改动,可能破坏整个系统。更稳的协议设计通常会考虑:

设计点目的
明确版本号知道双方使用哪套规则
可忽略未知字段方便向前兼容
保留字段给未来扩展留空间
严格长度/边界防止解析错位
明确错误码让调用方能按策略处理
幂等请求 ID让重试不重复副作用

你学网络协议时不要只背报文格式,要看它如何处理版本、乱序、超时、重复、错误恢复。

反例:新增字段也可能破坏旧解析器

很多人以为“只加字段,不删字段”一定兼容。这个判断只有在协议明确支持未知字段时才成立。

假设旧协议是定长格式:

version: 1 byte
type: 1 byte
length: 2 bytes
payload: length bytes

新版本想加一个 flags:

version: 1 byte
type: 1 byte
flags: 1 byte
length: 2 bytes
payload: length bytes

如果旧解析器仍按旧格式读,它会把 flags 当成 length 的高位:

旧解析器看到的 length = flags + length 的一部分

于是后面的 payload 边界全错。表面上只是加字段,实际破坏了所有旧参与者。

更稳的升级方式通常需要:

机制作用
version明确格式版本
feature negotiation双方确认是否支持新能力
length-delimited field允许跳过不认识的字段
reserved field预留未来扩展位置
default value旧端缺字段时仍有明确语义
strict rollout先升级解析能力,再发送新字段

协议兼容性的核心不是“新代码能读新格式”,而是“新旧参与者混在一起时,不会把同一串字节解释成不同世界”。

7. 分层为什么有用

分层把复杂问题拆开。

应用
-> 运行时 / 系统库
-> 操作系统
-> 指令集
-> 硬件

网络也分层:

应用层: HTTP / DNS / SSH
传输层: TCP / UDP / QUIC
网络层: IP
链路层: Ethernet / Wi-Fi
物理层: 电信号 / 光信号 / 无线电

每层只解决一类问题:

主要问题
应用层应用语义是什么
传输层进程到进程如何传输
网络层主机到主机如何寻址和路由
链路层相邻设备之间如何传一帧
物理层bit 如何变成信号

分层带来的好处是:上层可以复用下层能力,下层不需要理解上层语义。

7.1 分层的核心是“谁解决哪个问题”

分层不是为了画漂亮图,而是为了分配责任。

以一次 HTTPS 请求为例:

主要责任不负责什么
应用代码构造业务请求、处理业务响应不直接控制路由和重传
HTTP方法、路径、头、状态码、body 语义不保证加密和可靠传输
TLS身份认证、加密、完整性不理解业务字段
TCP可靠字节流、拥塞控制、顺序交付不知道 HTTP 消息边界
IP跨网络寻址和路由不保证可靠、不保证顺序
链路层相邻节点传帧不知道最终目的服务
物理层信号传输不理解 bit 的语义

当你知道每层“不负责什么”,排查时就不会把锅乱甩:

HTTP 500 不是 TCP 错。
DNS 解析失败不是 TLS 错。
TCP 连接不上时,业务 JSON 写得再对也没用。
TLS 证书错时,应用层 token 还没机会发挥作用。

8. 分层的代价

分层不是免费。

代价例子
额外头部网络每层都可能加 header
数据复制层与层之间可能复制 buffer
信息丢失上层不知道下层真实状态
调试困难问题可能跨多层传播
抽象泄漏下层限制影响上层行为

比如“网络请求慢”,可能来自:

DNS 慢
TCP 握手慢
TLS 握手慢
请求排队
服务端处理慢
磁盘 IO 慢
响应包丢失重传
客户端读取慢

如果你只停在“应用请求慢”,就很难定位。

8.1 跨层问题最容易误判

真实事故经常跨层传播:

数据库慢
-> 服务端线程被占满
-> 请求排队
-> 客户端超时
-> 客户端重试
-> 服务端压力更大
-> TCP 连接增多
-> 负载均衡开始丢弃连接

从客户端看只是“请求超时”。但根因可能是数据库、线程池、重试策略、连接池、负载均衡任何一环。

分层排查不是每层单独看,而是看信号如何跨层传递:

上层现象可能的下层信号
请求慢DNS 时间、连接时间、TLS 时间、TTFB、服务端处理时间
偶发失败重传、超时、连接池耗尽、服务端限流
文件写入慢页缓存脏页、磁盘队列、fsync、设备延迟
程序卡死锁等待、系统调用阻塞、IO 等待、线程池耗尽

这就是为什么高质量排查需要证据,而不是猜。

9. 联系实际:如何用分层思维排查问题

假设你访问一个远程资源失败,不要直接说“网络坏了”。按层拆:

检查点对应层
域名能解析吗?应用层 DNS
解析出的 IP 对吗?DNS / 网络配置
目标主机能到达吗?网络层
目标端口能连接吗?传输层
TLS 或认证能通过吗?安全协议层
请求格式对吗?应用协议
服务端是否处理了?应用逻辑
响应是否成功返回?应用层 + 传输层

每一层都可以收集证据。分层思维让排查从“猜原因”变成“逐层验证”。

9.1 抽象泄漏定位法

当一个抽象“不像它承诺的那样工作”时,可以按下面顺序追:

步骤问题例子
1. 确认上层契约接口到底承诺什么?send 承诺写入本地 buffer,还是对端业务处理?
2. 找到边界证据调用有没有跨过接口边界?是否发起系统调用、是否写入日志、是否发包
3. 分离成本和失败是慢、错、阻塞、丢失,还是语义误解?timeout 和 500 是不同层问题
4. 下钻一层下一层状态是什么?socket 状态、文件偏移、缓存状态、锁状态
5. 回到契约修复是调用方误用,还是接口契约不足?补错误码、补幂等键、补超时策略

例子:“保存成功后重启数据丢了”。

  • 上层以为:save 返回 = 数据持久化
  • 实际可能:save 返回 = 写入内存 buffer / OS 页缓存
  • 下层证据:没有 flush/fsync,崩溃时脏页没落盘
  • 修复方向:把接口契约拆成 saveToBuffer / flush / durableCommit,或者明确 save 的持久化语义

这比泛泛说“文件系统不可靠”更可操作。

设计卡:检查一个接口是否泄漏了底层混乱

拿任何接口,按这四问检查:

问题好接口的表现坏接口的味道
输入是否清楚?参数含义、单位、边界明确调用者要猜格式和默认值
输出是否稳定?返回值和错误语义可预测同一错误在不同路径表现不同
是否隐藏了实现细节?调用者不需要知道内部结构调用者必须理解内部缓存、锁、文件布局
是否暴露必要失败?失败类型可区分全部变成“失败了”

练习:把一个“保存数据”的接口拆成两版:一版只返回 true/false,另一版区分校验失败、容量不足、权限不足、写入中断。比较调用者后续能做什么,你会看到抽象不是把信息藏起来,而是只暴露正确层次的信息。

问题:给一个上传接口重新设计契约

学完本章,可以解决一个很常见的工程问题:

用户上传文件,接口返回 success。
但后来发现文件有时不可下载、有时只有一半、有时重复上传生成多个记录。

不要先写代码,先把接口契约补完整:

契约点需要明确
成功含义success 是收到请求、写入临时文件、校验通过、还是持久化完成?
文件所有权上传中临时文件谁清理?完成后谁负责引用?
原子性元数据记录和文件内容是否一起提交?
幂等性同一个上传重试是否复用同一个 upload_id?
部分失败内容写入成功但元数据失败怎么办?
可见性文件什么时候对下载接口可见?
错误语义格式错误、容量不足、权限不足、存储失败如何区分?

一个更清楚的状态机可能是:

INIT -> RECEIVING -> STORED_TEMP -> VERIFIED -> COMMITTED -> VISIBLE
-> FAILED

接口可以拆成:

createUpload() -> upload_id
appendChunk(upload_id, chunk)
completeUpload(upload_id) -> committed_file_id
getUploadStatus(upload_id)

这样调用者知道每一步处在哪个状态,失败后也知道能不能重试、需不需要清理。

10. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 为什么文件、进程、Socket、HTTP 都是抽象?
  2. 为什么“写成功”“发送成功”不一定代表真正落盘或到达对方?
  3. 如何判断一个接口是否清楚:输入、输出、错误、状态变化、资源责任。
  4. 为什么协议不仅有格式,还有状态和顺序?
  5. 网络、OS、文件系统问题如何按层拆,而不是混在一起猜。
  6. 为什么抽象有时会泄漏底层成本,比如复制、阻塞、重传、缓存失效。

如果你以后遇到复杂系统问题,能先问“这是哪一层的抽象?接口契约是什么?底层成本在哪里泄漏?”,你就能更快抓到问题的结构。

延伸阅读