流:Stream 与背压,以及什么时候不该用
到目前为止,每个 Effect 都恰好产出一个值(或失败)。但现实里很多数据不是「一个值」:分页接口一页接一页,大文件一行接一行,消息队列一条接一条。这一章讲 Effect 怎么描述「零到多个值」,以及为什么这里的关键词是背压。
朴素做法为什么不够
处理多个值,朴素做法大致三种:
- 整个读进数组再处理。小数据没问题;数据量大或没有终点(消息订阅)时,内存直接成为上限,甚至根本不会结束。
- 手写
while循环拉分页。能控制内存,但「拉一页、处理、判断是否有下一页、出错怎么办、被取消怎么办」全部要自己织在循环里。 AsyncIterable/for await。形态接近了,但它不携带类型化的错误通道,不携带依赖,取消和资源释放全靠调用方自觉。
三种做法共同缺的是同一件东西:一个能同时表达「多个值、会失败、需要依赖、可被中断」的描述。
Effect 的做法:Stream 是多值的 Effect
Stream<A, E, R> 和 Effect<A, E, R> 三个参数含义一致,区别只在「成功」的形态:Effect 恰好产出一个 A,Stream 产出零到多个 A。它同样是惰性的描述:构造一个 Stream 什么都不会发生,直到某个「消费」操作(run*)才真正启动,而且每次运行都会重新执行源头。这点和第 2 章的 Effect 完全一致,所以你可以安全地定义一个 Stream 常量,再在不同地方各运行一次。
Stream 的真正价值在拉取式的运行模型。下游消费者向上游要数据,上游才产生;下游处理得慢,上游自然就慢下来,不会无限堆积。这叫背压(backpressure)。可以把它想成一条传送带:工人(消费者)拿走一件,传送带才前进一格,而不是工厂不管不顾地往仓库里倒货。
结果是:不管源头有一百万行还是永远没有尽头,只要每个阶段的处理是有界的,内存占用就是有界的。
三步心智模型:构造、变换、消费
用 Stream 写程序,几乎总是这三步。
构造:把一个数据源描述成 Stream。
Stream.fromIterable:从数组等同步集合。Stream.paginate(初始游标, 取一页):分页接口。回调返回「本页元素数组」和「下一个游标的Option」,Option.none()表示结束。Stream.fromQueue、Stream.fromPubSub:把第 18 章的队列接成流。Stream.fromAsyncIterable(iterable, onError):包装已有的异步迭代器,同时要求你说明错误怎么翻译成类型化失败。Stream.callback:桥接回调式的事件源。
变换:像处理数组一样处理元素,只是它是惰性的。Stream.map、Stream.filter、Stream.take,以及最重要的 Stream.mapEffect:每个元素做一次有副作用的操作,可通过 concurrency 选项控制同时处理几个(并发度的取舍第 14 章已讲,这里同一套道理)。Stream.groupedWithin(n, duration) 按「满 n 个或到时间」成批,适合批量写入。
消费:用一个 run* 把 Stream 变回 Effect。
Stream.runDrain:只要副作用,丢弃元素。Stream.runForEach:对每个元素执行动作。Stream.runFold:折叠成一个结果。Stream.runCollect:收集成数组(v4 里返回的是普通数组)。
一个小例子:把分页接口变成一条流
import { Effect, Option, Stream } from "effect"
interface Page {
readonly items: ReadonlyArray<string>
readonly next: string | null
}
declare const fetchPage: (cursor: string | null) => Effect.Effect<Page, FetchError>
declare const saveBatch: (rows: ReadonlyArray<string>) => Effect.Effect<void, DbError>
interface FetchError { readonly _tag: "FetchError" }
interface DbError { readonly _tag: "DbError" }
const allItems = Stream.paginate(null as string | null, (cursor) =>
fetchPage(cursor).pipe(
Effect.map((page) => [
page.items,
page.next === null ? Option.none() : Option.some(page.next)
] as const)
)
)
export const importAll = allItems.pipe(
Stream.groupedWithin(100, "2 seconds"),
Stream.mapEffect(saveBatch, { concurrency: 2 }),
Stream.runDrain
)读这段代码,注意三件事。第一,没有任何地方持有「全部数据」,一页进来、成批写出,内存只与批大小和并发度有关。第二,错误通道是类型化的:importAll 的 E 是 FetchError | DbError,拉页失败和写库失败都会让整条流失败。第三,如果外层把它 Effect.timeout 或中断了,拉页和写库会一起被中断,不需要手动清理循环。
buffer:给两端解耦,但不是免费的
默认的拉取是紧耦合的:下游没要,上游就不动。有时你希望生产者能提前跑一点,抹平速度的小抖动,这时用 Stream.buffer({ capacity })。缓冲区满了怎么办是一个取舍,对应三种策略:
- 默认的
suspend:满了就让上游等,仍然是背压,数据不会丢。 dropping:满了丢新来的。sliding:满了丢最旧的。
后两种是明确地用丢数据换不阻塞,适合「只关心最新状态」的场景(比如实时指标),不适合订单、支付这类不能丢的数据。选哪一种是业务决定,不是性能调优。
什么时候不该用 Stream
Stream 是有成本的:多一层抽象、多一套 API。以下情形,更简单的工具更合适:
- 数据量小且有限。几十条记录,直接用数组加
Effect.forEach即可,需要并发就加concurrency(第 14 章)。为十条数据引入 Stream 是过度设计。 - 只是一次请求、一次响应。一个值就是一个 Effect。
- 你需要的是共享或广播,而不是流水线。多个消费者要看到同一份事件,这是 PubSub 的问题,不是 Stream 的问题(下一章)。Stream 每次运行都是独立的一次执行,不是一份被共享的「热」数据。
- 你需要按元素精确地协调状态。这更像状态管理,用 Ref、Queue 等原语更直接。
一个简单的判断:如果你的代码里出现了「循环里不断取下一批」「不知道会有多少条」「处理速度可能跟不上产生速度」,考虑 Stream;否则先用数组。
常见误区
- 对无界流用
runCollect。它会等到流结束再返回数组,无界的流永远不会结束,内存先爆。无界流用runDrain、runForEach,或先take限制个数。 - 把 Stream 当数组重复消费。每次
run*都会重新执行源头:如果源头是分页接口,就会重新发起一遍请求。需要复用结果,先收集,不要期望流「记住」。 mapEffect默认顺序执行。不指定concurrency时是一个接一个,想并发必须显式写,并且要有上限。- 把 buffer 当性能开关。buffer 只是移动了等待发生的位置;如果消费者持续慢于生产者,缓冲区终究会满,要么阻塞要么丢数据。
- 在流里吞掉错误。流中任何一步失败都会终止整条流。需要「单条失败不影响其他」时,要在每个元素内部把失败转成值(例如用第 14 章的
Effect.result把失败变成Result值),而不是在最外层兜底。
小结
Stream 是「多值的 Effect」:惰性、类型化、可中断,并且靠拉取式运行获得背压,让内存与吞吐的关系变得可控。用的时候就是构造、变换、消费三步;不该用的时候,数组加 forEach 往往更好。下一章讲当多个 fiber 要共同持有状态、互相传递数据时,Ref、Queue、PubSub、Semaphore 各自解决什么问题,怎么选。