现在,是时候把所有的理论付诸实践了,我们将通过设计和开发一个分布式系统来实现这一点,并从不同角度分析它的每个部分。
我们将深入探索交易的世界,尤其是证券交易市场(包括外汇市场)以及这类系统涉及的各个方面。
6.1 业务需求
一个虚构的服务级别协议(SLA)要求我们要开发的分布式系统必须具备高可用性,可以容忍最终一致性,并且能够支持每天至少一百万用户。系统主要包括两大部分:交易和行情预测。
交易
交易功能应允许用户通过出价和挂单(也称为买单和卖单)买卖外币。交易功能可以像开关一样被开启或关闭。
当市场中的买方愿意以市场上最优卖价成交,或者卖方愿意以市场上最高买价成交时,就会发生一笔交易或成交。买价(Bid)代表买方愿意为某种货币支付的最高价格,卖价(Ask)则代表卖方愿意接受的最低价格。
用户还应该能够通过 WebSocket 方式订阅某个货币对(如 EURPLN 或 GBPCHF)的提醒。当有新的买单或卖单出现时,系统需要判断是否应该发出新的提醒。
目前,已有一个配套的网页应用,具备如下用户界面。
提醒(Alert) 评估部分目前还没有详细说明,所以在进一步通知之前,我们可以先实现一些占位的逻辑(dummy logic)。
行情预测(Forecasting)
预测功能允许作者自助注册,然后发布市场预测类文章,比如“GBPUSD空头交易机会”等。每一条预测都可以被匿名点赞或点踩(类似于“喜欢”或“不喜欢”)。
与交易功能不同,预测部分目前既没有网页应用,也没有更多详细需求,后续可能会补充。因此,现在我们只会部署单实例的 forecasts 服务(第7章会详细介绍)。
6.1.1 总览
系统将包含以下服务,代表核心交易功能:
- processor:读取交易指令(如挂买单/卖单)和开关指令(如交易开/关),在合适时更新交易状态,并发出交易事件和开关事件。
- alerts:读取交易事件和开关事件,根据配置发出提醒(目前用占位逻辑)。
- ws:读取提醒信息,并通过 WebSocket 将其广播给所有订阅了该货币对的用户。
这些指令由外部开发团队发布到 Pulsar topic,所以我们需要做的,就是把我们的系统无缝集成进去(即可插拔式系统)。
本章我们将深入解析每个服务的细节,并进一步了解整体系统设计决策。
首先,让我们分析每个核心服务的领域和定位。
6.1.2 领域建模(Domain modeling)
我们应用的领域模型定义在 domain 模块下,这个模块被所有服务共享。除了其他数据类型之外,大部分 newtype 类型都采用了第4章中介绍的编码方式。例如:
package trading.domain
type Quantity = Quantity.Type
object Quantity extends NumNewtype[Int]
type Timestamp = Timestamp.Type
object Timestamp extends Newtype[Instant]
这些类型都作为顶层数据类型定义在 trading.domain 包中。同时,我们也定义了一些枚举,比如:
enum TradeAction derives Codec.AsObject, Eq, Show:
case Ask, Bid
Typeclass 派生(derivation)在本书成稿时还有一些细节问题,但大部分问题应该会被解决。
至于其他如 command、event 和 state 等类型,我们会在本章后续逐步揭晓(顺便玩了个函数式梗 traverse)。
6.1.3 共享模块(Shared modules)
所有服务都依赖 core 和 lib 这两个模块,此外还有 domain 模块。
-
core:定义需要被多个服务调用的业务逻辑和解释器。例如交易状态机 FSM:
object TradeEngine: val fsm: FSM[ Id, TradeState, TradeCommand | SwitchCommand, (EventId, Timestamp) => SwitchEvent | TradeEvent ] = FSM.id { ... }以及 Pulsar topic 的静态定义:
sealed abstract class AppTopic: def name: String def make(cfg: Config): Topic.Single object AppTopic: case object Alerts extends AppTopic: val name: String = "trading-alerts" def make(cfg: Config): Topic.Single = mkPersistent(cfg, name) -
lib:定义能力型 trait 等工具类。例如:
trait GenUUID[F[_]]: def make[A: IsUUID]: F[A] trait Time[F[_]]: def timestamp: F[Timestamp]
想要对所有共享组件有个全面了解,可以直接查看 trading 项目仓库,并结合本书内容一起学习。
6.2 处理器(Processor)
现在,终于要进入正题,开始探索 processor 服务的职责了。从应用架构图可以看到,它消费 TradeCommand 和 SwitchCommand,并分别生产 TradeEvent 和 SwitchEvent。
这其实遵循了 CQRS 设计模式(见 CQRS/ES),不过这里只涉及命令(Command),没有查询(Query),因为这里不需要。
首先,让我们来看这些数据类型的定义。
6.2.1 命令(Commands)
TradeCommand 代表创建、更新或删除买单(Bid)或卖单(Ask)的指令。在 Scala 3 中,我们通常把命令和事件都编码为 enum(当然也可以用 sealed trait),并带有一些抽象方法:
enum TradeCommand derives Codec.AsObject, Eq, Show:
def id: CommandId
def cid: CorrelationId
def symbol: Symbol
def createdAt: Timestamp
这四个字段都是系统中的核心信息:
id:唯一的命令标识符。cid:用于追踪系统事务的相关标识符(correlation id)。symbol:唯一标识的交易符号(如 EURUSD),代表买/卖的对象。createdAt:命令创建时间戳。
该枚举下有多个具体的数据类型,例如:
case Create(
id: CommandId,
cid: CorrelationId,
symbol: Symbol,
tradeAction: TradeAction,
price: Price,
quantity: Quantity,
source: Source,
createdAt: Timestamp
)
case Update(
id: CommandId,
...
)
case Delete(
id: CommandId,
...
)
(大部分字段已省略,理解即可。)
SwitchCommand 用于切换交易的开关(开启或关闭交易),其编码方式与上面类似:
enum SwitchCommand derives Codec.AsObject, Eq, Show:
def id: CommandId
def cid: CorrelationId
def createdAt: Timestamp
case Start(
id: CommandId,
cid: CorrelationId,
createdAt: Timestamp
)
case Stop(
id: CommandId,
cid: CorrelationId,
createdAt: Timestamp
)
这里只需要这三个字段,因为开关命令作用于整个交易,而不是某个具体的 symbol。
6.2.2 事件(Events)
TradeEvent 代表由于处理命令而发生的某个事件:
enum TradeEvent derives Codec.AsObject:
def id: EventId
def cid: CorrelationId
def command: TradeCommand
def createdAt: Timestamp
CorrelationId 总是对应触发该事件的命令的 cid,便于追踪系统内的事务。关于这一字段的重要性,后续在第8章会详细介绍(可观测性)。
枚举下主要有两个事件类型:
case CommandExecuted(
id: EventId,
cid: CorrelationId,
command: TradeCommand,
createdAt: Timestamp
)
case CommandRejected(
id: EventId,
...
)
(同样省略部分细节)
SwitchEvent 则对应三种不同的结果:
enum SwitchEvent derives Codec.AsObject, Show:
def id: EventId
def cid: CorrelationId
def createdAt: Timestamp
case Started(...)
case Stopped(...)
case Ignored(...)
6.2.3 命令与事件的关系(Command-event relationship)
理解命令和事件之间的关系,是搞清楚状态转换(由主交易状态机 TradeEngine 定义)的关键。
接下来我们会看到,这些命令如何驱动不同的事件和状态变迁。
| 状态 | 命令 | 事件 |
|---|---|---|
| On | Create | CommandExecuted |
| On | Update | CommandExecuted |
| On | Delete | CommandExecuted |
| Off | C-U-D | CommandRejected("trading off") |
| Off | Start | Started |
| On | Stop | Stopped |
| On | Start | Ignored |
| Off | Stop | Ignored |
说明:
- On/Off 代表当前的交易状态(开启/关闭)。
- C-U-D 代表 Create、Update、Delete 命令。
- CommandExecuted/CommandRejected/Started/Stopped/Ignored 分别是不同类型的事件名称。
- 当交易关闭(Off)时,任何 Create、Update、Delete 命令都会被拒绝(CommandRejected),理由为“trading off”。
前两列表示状态机的输入,而最后一列表示输出。
当交易处于开启状态时,如果收到 Create、Update 或 Delete 这类命令,将会生成一个 CommandExecuted 事件,如前三行所示。
相反,如果收到上述三种命令中的任何一种且交易关闭,则会生成 CommandRejected 事件。
交易状态可以通过 Start 和 Stop 命令进行切换,如关系表中的状态转换所示。
另外,如果当前状态已经符合预期,这些命令有时会被忽略。
我们在第5章中已经简单看过了交易状态机(FSM),其函数签名如下:
val fsm = FSM.id[
TradeState,
TradeCommand | SwitchCommand,
(EventId, Timestamp) => TradeEvent | SwitchEvent
]
输出被表示为函数 (EventId, Timestamp) => TradeEvent | SwitchEvent,以保证纯函数特性。
当然,我们也可以直接用 TradeEvent | SwitchEvent 作为输出,但那样的话就需要额外的效果能力来生成上述两个必需的输入参数(EventId 和 Timestamp)。
这里只是展示了两种不同的设计方式,实际上都可以用。稍后我们会进一步了解处理器服务中实际用到的状态机。
我们还有第二个状态机,它是基于事件(event)而不是命令(command)驱动的:
val eventsFsm = FSM.id[TradeState, TradeEvent | SwitchEvent, Unit]
不过,这个状态机不会输出任何内容(Unit),我们只关注状态的变更。
| STATUS | EVENT | NEW STATE |
|---|---|---|
| On | CommandExecuted | Yes |
| Off | CommandExecuted | No |
| Off | Started | Yes |
| On | Stopped | Yes |
| _ | CommandRejected | No |
| _ | SwitchEvent | No |
现在我们对事件、命令与内部状态的关系有了基本理解,接下来可以深入看看处理器服务的核心实现。
6.2.4 入口(Entry point)
所有服务都有类似的入口点——一个名为 Main 的对象,继承自 IOApp.Simple,并定义了一系列资源,比如消费者、生产者等。
我们以 processor 的 Main 为例详细分析其设计,因为里面很多“活动部件”与其他服务共用。
首先,我们为 TradeCommand 创建了一个 Pulsar 订阅,类型为 KeyShared(详见 Subscriptions),这样可以支持多个 processor 实例并发运行(关于可扩展性,后面会有详细说明)。
def cmdSub(id: AppId) =
Subscription.Builder
.withName(id.name)
.withType(Subscription.Type.KeyShared)
.build
AppId 是一个简单的数据类型,用于唯一标识每个服务实例:
case class AppId(name: String, id: UUID)
接下来,我们为 SwitchCommand 创建了一个 Pulsar 订阅,这种订阅对每个实例是独占(Exclusive)的,因此在 id.show 里包含了唯一标识:
def swtSub(id: AppId) =
Subscription.Builder
.withName(id.show)
.withType(Subscription.Type.Exclusive)
.build
另一个重要属性是,SwitchCommand 对应的 topic 被设置为支持 compact(压缩),因此消费者要做相应配置以从中读取:
val compact =
PulsarConsumer.Settings[IO, SwitchCommand]().withReadCompacted.some
然后是生产者的配置,首先是 TradeEvent,它支持去重(deduplication),并按 symbol 分片:
val evtSettings =
PulsarProducer
.Settings[IO, TradeEvent]()
.withDeduplication
.withShardKey(Shard[TradeEvent].key)
.some
紧接着是 SwitchEvent 的生产者设置:
val swtSettings =
PulsarProducer
.Settings[IO, TradeEvent.Switch]()
.withDeduplication
.withMessageKey(Compaction[TradeEvent.Switch].key)
.some
它同样支持去重,不过是按分区(partitioned)而非分片(sharded)(细节见 Scalability 部分)。这主要用于 topic 压缩(compaction),相关内容在第3章有讲。
最后,是所有具备生命周期的资源初始化:
def resources =
for
config <- Resource.eval(Config.load[IO])
pulsar <- Pulsar.make[IO](config.pulsar.url, Pulsar.Settings().withTransactions)
_ <- Resource.eval(Logger[IO].info(s"Initializing: ${config.appId.show}"))
server = Ember.default[IO](config.httpPort)
cmdTopic = AppTopic.TradingCommands.make(config.pulsar)
evtTopic = AppTopic.TradingEvents.make(config.pulsar)
swcTopic = AppTopic.SwitchCommands.make(config.pulsar)
sweTopic = AppTopic.SwitchEvents.make(config.pulsar)
trProducer <- Producer.pulsar[IO, TradeEvent](pulsar, evtTopic, evtSettings)
swProducer <- Producer.pulsar[IO, SwitchEvent](pulsar, sweTopic, swtSettings)
sub1 = cmdSub(config.appId)
sub2 = swtSub(config.appId)
trConsumer <- Consumer.pulsar[IO, TradeCommand](pulsar, cmdTopic, sub1)
swConsumer <- Consumer.pulsar[IO, SwitchCommand](pulsar, swcTopic, sub2, compact)
engine = Engine.fsm(trProducer, swProducer, Txn.make(pulsar), trConsumer, swConsumer)
yield (server, trConsumer, swConsumer, engine)
第一步是加载配置(用的是 Ciris 库),然后创建带事务支持的 Apache Pulsar 连接。
Ember.default 方法位于 core 模块下,会创建一个 HTTP 服务器,支持健康检查接口和 Prometheus 监控。
接下来分别创建 TradingEvent 和 SwitchEvent 的生产者,然后依次创建各自命令的消费者。
6.2.5 有限状态机(FSM)
最后,我们创建了主逻辑用到的 FSM(有限状态机)。我们的大多数业务逻辑都会用有限状态机来编写,所以你会在其他服务中反复看到这种模式。
有趣的是,这个状态机内部会调用交易 FSM,这个 FSM 也会在其他服务中复用,后面我们还会进一步学习。
我们先回顾一下 FSM 的定义结构:
case class FSM[F[_], S, I, O](
run: (S, I) => F[(S, O)]
)
它有一个内部状态 S,并通过接收输入 I 并输出 O,来处理状态转移。
在我们的例子中,S 是 TradeState,I 是 TradeCommand 或 SwitchCommand 类型的消息。我们不产生任何输出(O = Unit),通常这意味着实际效果是在 F(比如 IO)中完成的。
TradeState 数据类型定义如下:
final case class TradeState(
status: TradingStatus,
prices: Map[Symbol, Prices]
) derives Eq, Show
其中,TradingStatus 是一个简单枚举,本质上等同于布尔值:
enum TradingStatus derives Eq, Show:
case On, Off
Prices 也是一个 case class,包含四个属性:
final case class Prices(
ask: Prices.Ask,
bid: Prices.Bid,
high: Price,
low: Price
) derives Eq, Show
object Prices:
type Ask = Map[AskPrice, Quantity]
type Bid = Map[BidPrice, Quantity]
正如下面的例子所示,仅仅为了修改 ask 价格,就需要写一大堆样板代码:
val st: TradeState = ???
val pold = st.prices.get(Symbol.EURUSD).getOrElse(Prices.empty)
val pnew = pold.copy(ask = pold.ask.updated(Price(3.41), Quantity(2)))
val nst = st.copy(prices = st.prices.updated(Symbol.EURUSD, pnew))
为了简化操作这些嵌套结构的代码,我们引入了 Monocle 的 optics(光学镜片),所有这些操作的正确性都在 TradeStateSuite 中做了校验。
import monocle.{ Focus, Optional }
import monocle.function.{ At, Index }
object TradeState:
def empty: TradeState = TradeState(TradingStatus.On, Map.empty)
val _Status = Focus[TradeState](_.status)
val _Prices = Focus[TradeState](_.prices)
object __Prices:
def at(s: Symbol): Optional[TradeState, Option[Prices]] =
_Prices.andThen(At.atMap[Symbol, Prices].at(s))
def index(s: Symbol): Optional[TradeState, Prices] =
_Prices.andThen(Index.mapIndex[Symbol, Prices].index(s))
如果你对 optics 不熟悉,可以理解为它是一种用于数据操作的组合式函数抽象,具备一套代数法则。
比如,lenses 用于处理产品类型(如 case class),prisms 用于处理和类型(如 ADT),optionals 用于产品类型中可选的字段,等等。
Monocle 框架通过 Focus 类型极大地简化了这些复杂性,即使你并不知道什么是 lens 也可以直接上手。
上述代码中的 At 和 Index optics 让我们可以操作 List、Map 这类可索引的数据结构。它们大致的类型类定义如下:
class Index[S, -I, A]:
def index(i: I): Optional[S, A]
class At[S, -I, A]:
def at(i: I): Lens[S, A]
两者很像,只不过一个返回 Optional,一个返回 Lens。PFPS 这本书对 optics 有详细介绍,并推荐了进一步学习的资料。
在不同的状态机里,还用到了更多 optics。我们还在 TradeState 上定义了一些便捷方法,减少样板代码:
def modify(symbol: Symbol)(
action: TradeAction, price: Price, quantity: Quantity
): TradeState = ???
def remove(symbol: Symbol)(
action: TradeAction, price: Price
): TradeState = ???
抱歉刚才讲 optics 有点跑题,接下来我们看下 FSM 的构造方法:
object Engine:
type In = Either[Consumer.Msg[TradeCommand], Consumer.Msg[SwitchCommand]]
def fsm[F[_]: GenUUID: Logger: MonadCancelThrow: Time](
producer: Producer[F, TradeEvent],
switcher: Producer[F, SwitchEvent],
pulsarTx: Resource[F, Txn],
tradeAcker: Acker[F, TradeCommand],
switchAcker: Acker[F, SwitchCommand]
): FSM[F, TradeState, In, Unit] =
FSM { ??? }
它接收两个生产者(分别处理不同的事件类型)、一个 Pulsar 事务资源,以及两个命令的 acker。
接下来,两个命令的处理会在同一个 Pulsar 事务中进行:
FSM {
case (st, Right(Consumer.Msg(msgId, _, cmd))) =>
sendEvent(switchAcker.ack(msgId, _), st, cmd)
case (st, Left(Consumer.Msg(msgId, _, cmd))) =>
sendEvent(tradeAcker.ack(msgId, _), st, cmd)
}
sendEvent 辅助方法定义如下:
def sendEvent(
ack: Txn => F[Unit],
st: TradeState,
cmd: TradeCommand | SwitchCommand
): F[(TradeState, Unit)] =
pulsarTx.use { tx =>
val (nst, evt) = TradeEngine.fsm.run(st, cmd)
(GenUUID[F].make[EventId], Time[F].timestamp).mapN(evt).flatMap {
case e: TradeEvent => producer.send(e, tx)
case e: SwitchEvent => switcher.send(e, tx)
} *> ack(tx).tupleLeft(nst)
}.handleErrorWith { e =>
Logger[F].warn(s"Transaction failed: ${e.getMessage}").tupleLeft(st)
}
在这个流程里,事务保证了命令消费的确认(ack)和对应事件的发布会作为单一的原子操作来完成。
接下来我们会进一步分析这种方案以及其他替代实现(详见 Deep analysis 部分)。
6.2.5.1 测试
有限状态机(FSM)的一大好处就是很容易进行隔离测试,因为它像一个普通函数那样,接收输入、产生输出。
在下面的交易 FSM 测试中,我们可以看到命令与事件、以及内部状态转移之间的关系(详见命令-事件关系)。
test("Trade engine commands fsm") {
val cmd1 = TradeCommand.Create(id, cid, s, TradeAction.Ask, p1, q1, "test", ts)
val (st1, ev1) = fsm.run(TradeState.empty, cmd1)
val xst1 = TradeState(
status = On,
prices = Map(s -> Prices(ask = Map(p1 -> q1), bid = Map.empty, p1, p1))
)
val xev1 = TradeEvent.CommandExecuted(eid, cid, cmd1, ts)
val cmd5 = SwitchCommand.Stop(id, cid, ts)
val (st5, ev5) = fsm.run(st4, cmd5)
val xst5 = TradeState(Off, xst4.prices)
val xev5 = SwitchEvent.Stopped(eid, cid, ts)
val cmd6 = TradeCommand.Create(id, cid, s, TradeAction.Bid, p1, q1, "test", ts)
val (st6, ev6) = fsm.run(st5, cmd6)
val xst6 = xst5
val xev6 = TradeEvent.CommandRejected(eid, cid, cmd6, Reason("Trading is Off"), ts)
// expectations 2 to 5 skipped for brevity
NonEmptyList
.of(
expect.same(st1, xst1),
expect.same(st6, xst6),
expect.same(ev1(eid, ts), xev1),
expect.same(ev6(eid, ts), xev6)
)
.reduce
}
我们把所有 Weaver 的断言(即期望)放在一个 NonEmptyList 里,然后调用 reduce,因为它们本身就形成了一个 Monoid。
所有未展开的值都是在测试对象顶层声明的常量,完整版本请参考源代码。
6.2.6 深度分析
现在,我们来分析主 FSM 中做出的决策及可能遇到的问题。
当我们收到 TradeCommand 或 SwitchCommand 时,会运行交易 FSM,得到新的 TradeState 以及一个函数 (EventId, Timestamp) => TradeEvent | SwitchEvent。
接着,在一个事务块中执行一系列 Pulsar 操作:
- 发布 trading 或 switch 事件;
- 确认命令已成功接收(acknowledge)。
代码如下:
pulsarTx.use { tx =>
val (nst, evt) = TradeEngine.fsm.run(st, cmd)
(GenUUID[F].make[EventId], Time[F].timestamp).mapN(evt).flatMap {
case e: TradeEvent => producer.send(e, tx)
case e: SwitchEvent => switcher.send(e, tx)
} *> ack(tx).tupleLeft(nst)
}.handleErrorWith { e =>
Logger[F].warn(s"Transaction failed: ${e.getMessage}").tupleLeft(st)
}
这些操作要么作为一个原子动作一起执行,要么事务回滚,此时内部状态不会被更新。
如果没有事务,命令就会被重新处理,并且每次确认失败时都会生成同样的事件。为了对事件去重(deduplicate),我们就需要跟踪已经处理过的命令,为其分配相同的 Sequence ID,或者在消费端做去重(详见 Deduplication),这样会增加复杂度。因此我们选择使用事务来简化问题,即便这样会带来一定性能损耗。
不过,事务也不是万能的。原子性和幂等性只对 Pulsar 操作有保证。如果我们在事务中间执行其他副作用操作,这部分就要我们自己负责了。
在我们的场景中,所有外部副作用都是和 Pulsar 的交互,因此幂等性有保证。但如果中间加了数据库操作,会发生什么呢?
比如:
for
_ <- producer.send(evt, tx)
_ <- hypotheticalDB.save(nst)
_ <- ack(msgId, tx)
yield ()
下面我们逐行分析失败时会发生什么:
- 如果事件发布失败(
producer.send(evt, tx)),不会有任何动作(如果未处理异常则服务挂掉),命令会在重启(或被其他实例)时再次被处理。 - 如果保存 TradeState 到数据库失败(
hypotheticalDB.save(nst)),Pulsar 事务会失败,因为不会到达确认(ack)阶段,但我们还要分辨是哪类失败。如果只是保存失败,可以安全重试吗?如果其实已经写入,只是在响应前连接断开,那再重试就可能不安全了,尤其当该操作不是幂等的。
因此,唯一安全的办法是确保数据库写入是幂等的,比如给数据分配主键。另一种替代方案是使用 CRDTs,第2章里有简单介绍(见 Consistency 部分)。
你可能会想把数据库事务和 Pulsar 事务绑在一起——只要事务块出错,两边都会回滚:
(pulsarTx, hypotheticalDB.tx).tupled.use {
case (tx, _) =>
for
_ <- producer.send(evt, tx)
_ <- hypotheticalDB.save(nst)
_ <- ack(msgId, tx)
yield ()
}.handleErrorWith { e =>
Logger[F].error(e) *> nack(msgId)
}
但实际上我们无法保证“两边资源的提交”都能成功,比如 commit 数据库和 Pulsar 事务时如果网络故障就可能出错。
更优雅的做法是采用 事务外箱(transactional outbox)模式(见第2章 Outbox pattern),专门为此类问题设计。
这种分析对于理解系统非常关键。多问 “What if...?”(如果…会怎样)有助于暴露设计中的风险和漏洞,让我们的系统更健壮。
6.2.7 可扩展性
KeyShared 类型的订阅允许我们同时运行多个 processor 实例。那么,在实际中这会是什么样子?我们又是如何处理跨多个实例分布的状态的呢?
图 6.3 展示了在这种模式下运行的三个不同 processor 实例。
图 6.3:processor 可扩展性(trading)
分片(sharding)逻辑通过 Shard 实例进行配置(详见 Pulsar Producer):
given Shard[TradeCommand] with
val key: TradeCommand => ShardKey =
cmd => Shard.by(cmd.symbol.show)
这确保了同一时间只有一个实例会处理相同的 symbol,因此我们可以放心地独立处理聚合操作。
需要注意的是,processor 服务并不关心价格的波动,它只关心事件的生成,而事件又依赖于当前的交易状态。
因此,服务总是可以从一个空的 TradeState 启动。每个实例都对 switch-commands 的 compacted topic 拥有独占订阅,这保证了快速启动,因为只需要读取最新的状态值(即 on/off)。
图 6.4 展示了 switch 命令的处理方式。
从 compacted topic(压缩主题)中读取数据,需要使用 Exclusive 或 Failover 类型的订阅。我们通过为每个实例分配一个唯一的标识符(AppId)来实现这一点。
此外,SwitchCommands 和 SwitchEvents 都需要按 key 进行分区,这由 Compaction 实例来决定:
given Compaction[SwitchCommand] with
val key: SwitchCommand => MessageKey = {
case _: SwitchCommand.Start => by("start")
case _: SwitchCommand.Stop => by("stop")
}
given Compaction[SwitchEvent] with
val key: SwitchEvent => MessageKey = {
case _: SwitchEvent.Started => by("started")
case _: SwitchEvent.Stopped => by("stopped")
case _: SwitchEvent.Ignored => by("ignored")
}
当一个 topic 被压缩后,最多会有两个命令和三个事件分别被保留。
6.2.7.1 重新分配(Rebalance)
如果有新的实例加入,或者某个实例宕机了,会发生什么?在这两种情况下,Pulsar 都会自动重新分配(rebalance)活跃消费者。
图 6.5 展示了 P2 实例不可用时触发的重新分配过程。
在有状态服务中(详见 Alerts Scalability),这类情况可能会非常棘手,但在这里(无状态或状态易恢复),这反而让我们的实现变得简单。
6.2.8 运行
最后,这就是我们运行 processor 应用的方式:
def run: IO[Unit] =
Stream
.resource(resources)
.flatMap { (server, trConsumer, swConsumer, fsm) =>
Stream.eval(server.useForever).concurrently {
trConsumer.receiveM
.either(swConsumer.receiveM)
.evalMapAccumulate(TradeState.empty)(fsm.run)
}
}
.compile
.drain
所有服务都提供了 HTTP 接口用于健康检查和指标采集,因此我们通过 useForever 持续运行 HTTP 服务的同时,并发地消费命令并将其送入 FSM 执行。
默认情况下,receiveM 会从最后一个未确认的消息开始消费。
6.3 告警(Alerts)
下一个核心服务是告警服务(alerts)。它会消费由 processor 产生的 TradeEvents 和 SwitchEvents,并发出 Alerts(如图 6.2 所示)。
此外,它还会消费 PriceUpdates,这部分我们将在后文介绍(见 Datatypes)。
与之前的服务不同,这里属于“监听并响应”类型,因此不遵循 CQRS 设计模式——也就是说,它是通过监听事件并生成告警来工作的。
不过,其实我们也可以把 processor 服务看成是监听命令并产生事件,本质上区别只在于命令传递的是指令,而事件只是告知某件事已经发生。
因此,本质上这些只是不同类型的服务,但其实有很多共通之处。
我们对 TradeEvent 已经很熟悉了,现在来了解下 Alert 和 PriceUpdate。
6.3.1 数据类型(Datatypes)
Alerts 可以看作另一种事件。事实上,完全可以把它重命名为 AlertEvent、NotificationEvent 等等,含义不会有任何变化。
和其他关键数据类型一样,我们规定了一些必填字段:
enum Alert derives Codec.AsObject, Show:
def id: AlertId
def cid: CorrelationId
def createdAt: Timestamp
接下来有两个不同的 case:
case TradeAlert(
alertType: AlertType,
symbol: Symbol,
askPrice: AskPrice,
bidPrice: BidPrice,
high: HighPrice,
low: LowPrice
)
case TradeUpdate(
status: TradingStatus,
)
TradeAlert 表示某个具体 Symbol 的变化:Symbol 是长度为六的字符串类型的新类型,比如 USDEUR、GBPCHF,使用 refinement types 定义。
type SymbolR = DescribedAs[
Match["^[a-zA-Z0-9]{6}$"],
"A Symbol should be an alphanumeric of 6 digits"
]
type Symbol = Symbol.Type
object Symbol extends Newtype[String :| SymbolR]
TradeUpdate 则表示交易状态的变更(On 或 Off)。
其他尚未提及的数据类型还包括 AlertType:
enum AlertType derives Show:
case StrongBuy, StrongSell, Neutral, Buy, Sell
所有和价格相关的数据类型都是 BigDecimal 的新类型,而 TradingStatus 就是我们前面学到的 On/Off。
最后,PriceUpdate 用于表示某个 symbol 的价格变更:
final case class PriceUpdate(
symbol: Symbol,
prices: Prices
) derives Codec.AsObject, Eq, Show
接下来我们会了解为什么需要这个数据类型。
6.3.2 事件与告警的关系
正如前面提到的,理解数据之间的关系对于理解整个系统至关重要。下面来看看告警是如何在服务 FSM 中被触发的:
| STATUS | EVENT | ALERT |
|---|---|---|
| Off | Started | TradeUpdate(On) |
| On | Stopped | TradeUpdate(Off) |
| On | Started | N/A |
| Off | Stopped | N/A |
| * | Ignored | N/A |
| * | PriceUpdate | N/A |
| * | CommandRejected | N/A |
| * | CommandExecuted | TradeAlert(*)/PriceUpdate |
前两行表示交易状态的变更。如果在状态为 On 时收到 Started 事件,则不会产生任何影响(见第三行),只会做消息确认,Stopped 也一样。
收到 PriceUpdate 时不会产生任何输出,只会引起内部状态变化(具体见下节 FSM)。
SwitchEvent.Ignored 和 TradeEvent.CommandRejected 事件只是做确认,不会生成告警。
最后,当收到 CommandExecuted 事件时,会生成 TradeAlert,在某些条件下还会生成 PriceUpdate(具体细节后面介绍)。
6.3.3 有限状态机(FSM)
告警服务的有限状态机同样以 TradeState 作为状态,但输入是一组消息(In),输出为空(Unit):
type In = Msg[TradeEvent | SwitchEvent | PriceUpdate]
FSM 构造方法如下:
def fsm[F[_]: GenUUID: Logger: MonadCancelThrow: Time](
appId: AppId,
alertProducer: Producer[F, Alert],
pricesProducer: Producer[F, PriceUpdate],
pulsarTx: Resource[F, Txn],
tradeAcker: Acker[F, TradeEvent],
switchAcker: Acker[F, SwitchEvent],
pricesAcker: Acker[F, PriceUpdate]
): FSM[F, TradeState, In, Unit] =
def mkIdTs: F[(AlertId, Timestamp)] =
(GenUUID[F].make[AlertId], Time[F].timestamp).tupled
FSM { ... }
图 6.6 展示了 alerts 服务如何生产和消费 PriceUpdate,这已经足以说明为什么 FSM 的输入类型之一是 PriceUpdate,并且其构造方法需要接收一个 Producer[F, PriceUpdate] 作为参数。我们稍后会解释这样做的原因(见 Scalability 部分)。
在上面 FSM 的大括号代码块中,包含了前面关系表里描述的所有状态转换。我们先来看最简单的几种情况,只需要做消息确认即可:
case (st, Msg(msgId, _, SwitchEvent.Ignored(_, _, _))) =>
switchAcker.ack(msgId).tupleLeft(st)
case (st, Msg(msgId, _, TradeEvent.CommandRejected(_, _, _, _, _))) =>
tradeAcker.ack(msgId).tupleLeft(st)
接下来是 PriceUpdate,只会引起内部状态变化:
case (st, Msg(msgId, props, PriceUpdate(symbol, prices))) =>
val nst = props.get("app-id") match
case Some(id) if id =!= appId.id.show =>
TradeState.__Prices.at(symbol).replace(Some(prices))(st)
case _ => st
pricesAcker.ack(msgId).tupleLeft(nst)
首先会检查消息属性:如果 app-id 与当前实例不同,就更新该 symbol 的内部价格,否则状态保持不变。具体细节会在 Scalability 部分展开。
接下来分析主角 CommandExecuted 事件的处理流程:
case (st, Msg(msgId, _, evt: TradeEvent.CommandExecuted)) =>
val nst = TradeEngine.eventsFsm.runS(st, evt)
val cmd = evt.command
val p = st.prices.get(cmd.symbol)
val c = nst.prices.get(cmd.symbol)
val previousAskMax: AskPrice =
p.flatMap(_.ask.keySet.maxOption).getOrElse(Price(0.0))
val currentAskMax: AskPrice =
c.flatMap(_.ask.keySet.maxOption).getOrElse(Price(0.0))
// 模拟交易市场的告警逻辑(简化版)
def alert(id: AlertId, ts: Timestamp): Alert =
if previousAskMax - currentAskMax > Price(0.3) then
TradeAlert(id, cid, StrongBuy, symbol, ..., ts)
else
TradeAlert(id, cid, Neutral, symbol, ..., ts)
val priceUpdate = c.flatMap { prices =>
(p =!= c).guard[Option].as(PriceUpdate(cmd.symbol, prices))
}
mkIdTs.map(alert).flatMap { alert =>
sendAck(alert, priceUpdate, tradeAcker.ack(msgId, _))
.tupleLeft(nst)
.handleErrorWith { e =>
Logger[F].warn(s"Transaction failed: ${e.getMessage}").tupleLeft(st)
}
}
首先通过交易事件 FSM 得到新的 TradeState。alert 方法模拟了告警发出的逻辑(实际细节略去),priceUpdate 只要该 symbol 的之前和现在价格有变化,就生成一个 PriceUpdate(否则为 None)。
最后,调用 sendAck 辅助方法,执行以下操作:
def sendAck(
alert: Alert,
priceUpdate: Option[PriceUpdate],
ack: Txn => F[Unit]
): F[Unit] =
pulsarTx.use { tx =>
for
_ <- alertProducer.send(alert, tx)
_ <- priceUpdate.traverse_ {
pricesProducer.send(_, Map("app-id" -> appId.id.show), tx)
}
_ <- ack(tx)
yield ()
}
在一个 Pulsar 事务中(详见 Transactions 部分),它会:
- 发送一个 Alert;
- 如果有 PriceUpdate,则包含当前 AppId 一起发送;
- 对输入消息进行确认(ack);
如果事务失败,内部状态不会变更。
最后,还要处理 Started 和 Stopped 事件:
case (st, Msg(msgId, _, evt: SwitchEvent)) =>
switch(evt.cid, msgId, st, TradeEngine.eventsFsm.runS(st, evt))
这个 case 在处理完 SwitchEvent.Ignored 后(为穷举模式匹配)出现,会调用 switch 辅助方法:
def switch(
cid: CorrelationId,
msgId: MsgId,
st: TradeState,
nst: TradeState
): F[(TradeState, Unit)] =
mkIdTs.map(TradeUpdate(_, cid, nst.status, _)).flatMap { alert =>
sendAck(alert, None, switchAcker.ack(msgId, _)).tupleLeft(nst)
.handleErrorWith { e =>
Logger[F].warn(s"Transaction failed: ${e.getMessage}").tupleLeft(st)
}
}
它会以新的 TradingStatus 发送一个 TradeUpdate 告警,同时处理潜在的事务失败。
6.3.4 入口(Entry point)
该服务的入口同样是一个 Main 对象,包含两种不同的订阅方式:
def trSub(appId: AppId) =
Subscription.Builder
.withName(appId.name)
.withType(Subscription.Type.KeyShared)
.build
def swSub(appId: AppId) =
Subscription.Builder
.withName(appId.show)
.withType(Subscription.Type.Exclusive)
.build
前者是用于 TradeEvents 的 KeyShared 订阅,后者则是带有唯一标识的 Exclusive 订阅,适用于 SwitchEvents 和 PriceUpdates。
接下来是 Alert 和 PriceUpdate 的生产者配置:
val altSettings =
PulsarProducer
.Settings[IO, Alert]()
.withDeduplication
.withMessageKey(Compaction[Alert].key)
.some
val priSettings =
PulsarProducer
.Settings[IO, PriceUpdate]()
.withDeduplication
.withMessageKey(Compaction[PriceUpdate].key)
.some
这两者都具备去重(deduplication)和消息 key,主要用于 topic 的 compaction。
最后是 SwitchEvents 的消费者配置:
val compact =
PulsarConsumer.Settings[IO, SwitchEvent]().withReadCompacted.some
然后是一系列资源的获取:
def resources =
for
config <- Resource.eval(Config.load[IO])
pulsar <- Pulsar.make[IO](config.pulsar.url, Pulsar.Settings().withTransactions)
_ <- Resource.eval(Logger[IO].info(s"Initializing: ${config.appId.show}"))
alertsTopic = AppTopic.Alerts.make(config.pulsar)
pricesTopic = AppTopic.PriceUpdates.make(config.pulsar)
switchTopic = AppTopic.SwitchEvents.make(config.pulsar)
tradingTopic = AppTopic.TradingEvents.make(config.pulsar)
snapshots <- SnapshotReader.make[IO](config.redisUri)
alertProducer <- Producer.pulsar[IO, Alert](pulsar, alertsTopic, altSettings)
pricesProducer <- Producer.pulsar[IO, PriceUpdate](pulsar, pricesTopic, priSettings)
xSub = swSub(config.appId)
tSub = trSub(config.appId)
trConsumer <- Consumer.pulsar[IO, TradeEvent](pulsar, tradingTopic, tSub)
swConsumer <- Consumer.pulsar[IO, SwitchEvent](pulsar, switchTopic, xSub, compact)
puConsumer <- Consumer.pulsar[IO, PriceUpdate](pulsar, pricesTopic, xSub)
server = Ember.default[IO](config.httpPort)
txn = Txn.make(pulsar)
engine = Engine.fsm(
config.appId, alertProducer, pricesProducer, txn, trConsumer, swConsumer, puConsumer
)
yield (server, trConsumer, swConsumer, puConsumer, snapshots, engine)
首先加载应用配置,然后获取支持事务的 Pulsar 连接,并创建所需的 topic。
SnapshotReader 用于在服务启动时读取交易状态的快照,使这个服务成为有状态服务:
trait SnapshotReader[F[_]]:
def latest: F[Option[(TradeState, Consumer.MsgId)]]
该服务只负责读取快照,写入端在另一个服务中(第7章会介绍)。
然后是 Alert 和 PriceUpdate 的生产者,以及用于状态机的三个消费者。
最后,创建 HTTP 服务器和 FSM。
6.3.5 可扩展性
乍一看,这个服务的架构和 processor 服务很相似。
TradeEvents 的消费是按照 Symbol 进行分片(KeyShared 订阅),这意味着服务的不同实例会各自处理唯一的 symbol,如图 6.7 所示。
除了发布 TradeEvents 之外,从前面的代码我们可以看到,alerts 服务还会将 PriceUpdates 发布到另一个 compacted topic。
PriceUpdates 会被每一个活跃的消费者消费,这一点我们很快就会看到。
类似于 processor 服务如何处理 SwitchCommands,这里我们通过 Exclusive 订阅的方式处理 SwitchEvents,如图 6.9 所示。
最后,图 6.10 展示了 PriceUpdate 的独占(Exclusive)订阅方式。
我们为什么需要生产和消费 PriceUpdate 呢?
简而言之,是因为消费者的再平衡(rebalance);也就是在有状态服务中,KeyShared 订阅类型本身带来的复杂性。
6.3.5.1 再平衡(Rebalance)
当某个实例宕机或有新实例加入时,Pulsar 会自动对活跃消费者进行再平衡,这一点我们在前面的服务(见 Processor rebalance)中已经了解过。
图 6.11 展示了 A2 实例不可用时触发的再平衡过程。
在启动时,每个实例都会读取最新的 TradeState 快照,然后通过 FSM 继续进行各自的聚合。我们先回顾一下状态的结构:
final case class TradeState(
status: TradingStatus,
prices: Map[Symbol, Prices]
) derives Eq, Show
初始快照包含了所有实例处理过的 symbol 的聚合信息。等我们揭开写入端的实现(见 Snapshots),才能看到全貌。
因此,当服务开始处理传入事件时,Pulsar 只会将特定 symbol 的事件分发给特定的实例,保证不会有多个实例处理同一个 symbol 的价格波动。
这意味着,在消费者发生再平衡(rebalance)后,正在运行的实例依然会保留之前(现在已经过时)的、原本由宕机实例处理的那些 symbol 的状态。
为此,每个实例都会把自己处理的 symbol 的 PriceUpdate 共享出去,以便其他实例随时保持最新状态,为可能到来的再平衡做准备。
通过在消息元数据中加入应用 ID,可以确保只处理来自其他实例的 PriceUpdate,从而避免重复操作。
如图 6.11 所示,在 A2 宕机后,A3 实例接管了 CHFGBP 的告警处理,由于已经具备该 symbol 的最新状态,可以直接继续处理告警。
当 A2 恢复上线时,又会触发一次消费者再平衡。这时,A2 会从 TradeState 快照重新开始,同时也能接收到 PriceUpdate 和 SwitchEvent。
这种方案,主要是为了应对有状态服务下 KeyShared 订阅带来的偶发复杂性。但其实我们还有两种可选方案:
- 使用 Failover 订阅取代 KeyShared。
- 对 KeyShared 订阅采用 Pulsar 的 sticky 一致性哈希(sticky consistent hashing)。
第一种方案最简单(其实这个服务最初就是这样设计的),不用考虑再平衡的问题,但无法在单一 topic 上扩展,只能有一个实例在运行。如果结合分区 topic 使用,可以实现扩展,但也会带来一系列新的挑战,其中一些与第二种方案类似(比如固定消费者数量,难以动态扩容)。
第二种方案也很靠谱,尽管写本书时还没有文档说明。它不是根据分片 key 动态路由消息,而是要求我们一开始就声明有多少消费者,以及每个消费者对应哪些哈希值(由分片 key 计算)。
这种方式保证了消费者不会再平衡,因此也就无需在实例间共享 PriceUpdate。不过,启动时必须固定实例数量,这比较局限。动态实例数可以让我们在高峰期增实例、低谷期减实例,更加节省成本。
另外一个大限制是可用性:如果某个实例不可用,所有消费者都会停止处理消息,直到该实例恢复。这是 Pulsar(自 v2.7.0 支持)这种实现方式的具体细节,但未来版本也许会改进。
了解解决同一问题的多种方案有助于我们更好地做系统设计,所以进行这类分析很有必要。
6.3.6 运行
让事件流入 FSM 的方式本身也有不少挑战。我们先来看 alerts 服务的 run 方法:
def run: IO[Unit] =
Stream
.resource(resources)
.flatMap { (server, trConsumer, swConsumer, puConsumer, snapshots, fsm) =>
Stream.eval(server.useForever).concurrently {
Stream.eval(snapshots.latest).flatMap {
case Some(st, id) =>
Stream.eval(IO.deferred[Unit]).flatMap { gate =>
trConsumer
.rewind(id, gate)
.either(Stream.exec(gate.get) ++ swConsumer.receiveM)
.either(Stream.exec(gate.get) ++ puConsumer.receiveM)
.union2
.evalMapAccumulate(st)(fsm.run)
}
case None =>
trConsumer.receiveM
.either(swConsumer.receiveM)
.either(puConsumer.receiveM)
.union2
.evalMapAccumulate(TradeState.empty)(fsm.run)
}
}
}
.compile
.drain
和往常一样,我们会在后台运行 HTTP 服务,同时并发地拉取最新 TradeState 快照并消费消息,将它们交给 FSM 处理。
但这里出现了新逻辑:如果拿到了快照,会让 TradeEvent 的消费者回溯(rewind)到快照对应的消息,然后才开始处理。这就是难点所在。
如果我们回溯 TradeEvent 的消费者,却继续消费 SwitchEvent 和 PriceUpdate,由于后两者来自 compacted topic(始终只保留唯一最新值),那么内部状态就会被搞乱。
所以,我们通过 Deferred[Unit] 来同步消费者回溯过程。
extension [F[_]: Monad, A](c: Consumer[F, A])
def rewind(
id: MsgId,
gate: Deferred[F, Unit]
): Stream[F, Msg[A]] =
Stream.eval(c.lastMsgId).flatMap { lastId =>
c.receiveM(id).evalTap { msg =>
gate.complete(()).whenA(lastId === Some(msg.id))
}
}
当所有 TradeEvent 都被处理到最后一条消息时,Deferred 会被 complete,这样 SwitchEvent 和 PriceUpdate 的消费者才会开始消费。
.either(Stream.exec(gate.get) ++ swConsumer.receiveM)
.either(Stream.exec(gate.get) ++ puConsumer.receiveM)
最后,union2 扩展方法定义如下:
extension [F[_], A, B, C](
src: Stream[F, Either[Either[Msg[A], Msg[B]], Msg[C]]]
)
def union2: Stream[F, Msg[A | B | C]] =
src.map {
case Left(Left(ma)) => ma.asInstanceOf[Msg[A | B | C]]
case Left(Right(mb)) => mb.asInstanceOf[Msg[A | B | C]]
case Right(mc) => mc.asInstanceOf[Msg[A | B | C]]
}
目前很多库对 union 类型的操作还不太完善,所以我们需要自己补充。有时用 merge 也可以,但这种场景下不适用。
总结一下,只有在深入思考过扩展性和各种边界情况后,才能设计出这样完善的解决方案。不然的话,可能根本发现不了这些潜在问题。
6.4 Web Sockets
最后一个核心服务负责通过 WebSocket(WS)处理告警和订阅。
用户连接后,可以订阅或取消订阅特定 symbol(如 EURUSD 或 GBPCHF)的告警——而且是全匿名的,不需要任何身份认证。
如图 6.12 所示,该服务会消费 Alerts,并处理 WS 消息。我们将在下面的章节中详细介绍这些内容。
6.4.1 数据类型(Datatypes)
通过 WebSocket(WS)进行通信有两类消息:出站消息和入站消息。
出站消息定义如下:
enum WsOut derives Codec.AsObject, Show:
case Attached(sid: SocketId)
case Notification(alert: Alert)
case OnlineUsers(n: Int)
入站消息定义如下:
enum WsIn derives Codec.AsObject, Show:
case Close
case Heartbeat
case Subscribe(symbol: Symbol)
case Unsubscribe(symbol: Symbol)
Attached 是在连接建立时发送给客户端的消息,包含唯一的 socket 标识。Notification 是来自 alerts 服务的任意告警。OnlineUsers 表示当前有多少用户连接到服务器。
此外,我们有一个扩展方法可以将任意 Alert 转换为 WsOut:
extension (alert: Alert)
def wsOut: WsOut = WsOut.Notification(alert)
对于入站消息,用户只能订阅或取消订阅某些 symbol,这就是该服务与用户交互的全部内容。
Close 和 Heartbeat 消息仅用于内部,用来保持 WebSocket 连接的健康状态并优雅地处理关闭(后文的事件处理部分会详细说明)。
6.4.2 HTTP 路由(HTTP routes)
WebSocket 的 HTTP 接口定义如下:
final class WsRoutes[F[_]: GenUUID: Logger: Monad](
ws: WebSocketBuilder[F],
mkHandler: SocketId => F[Handler[F]]
) extends Http4sDsl[F]:
val routes: HttpRoutes[F] = HttpRoutes.of {
case GET -> Root / "v1" / "ws" =>
GenUUID[F].make[SocketId]
.flatMap(mkHandler)
.flatMap(h => ws.build(h.send, h.receive))
}
mkHandler 函数接收唯一的 socket 标识,返回一个用于构建 Handler[F] 的函数(后文会介绍)。
Http4s 提供的 WebSocketBuilder 定义了如下的 build 方法:
def build(
send: Stream[F, WebSocketFrame],
receive: Pipe[F, WebSocketFrame, Unit],
): F[Response[F]]
因此,每当有新连接建立,就会生成一个唯一 SocketId 的 Handler,最后返回 WebSocket 响应。
6.4.3 事件处理器(Events handler)
WS 事件处理器是整个系统最复杂的部分之一,尽管接口非常简单,正好与 WebSocketBuilder#build 所需参数一致:
trait Handler[F[_]]:
def send: Stream[F, WebSocketFrame]
def receive: Pipe[F, WebSocketFrame, Unit]
其主构造函数接收三个参数:
def make[F[_]: Concurrent: Logger](
sid: SocketId,
conns: WsConnections[F],
alerts: Stream[F, Alert]
): F[Handler[F]] = ???
sid: 唯一 socket 标识符conns: 用于跟踪总 WS 连接数的接口alerts: 要处理的 Alerts 流
接着是 WsConnections 接口定义:
trait WsConnections[F[_]]:
def get: F[Int]
def subscriptions: Stream[F, Int]
def subscribe(sid: SocketId): F[Unit]
def unsubscribe(sid: SocketId): F[Unit]
其主实现基于 Fs2 的 SignallingRef:
object WsConnections:
def make[F[_]: Concurrent]: F[WsConnections[F]] =
SignallingRef.of[F, Set[SocketId]](Set.empty).map { ref =>
new:
def get: F[Int] = ref.get.map(_.size)
def subscriptions: Stream[F, Int] = ref.discrete.map(_.size)
def subscribe(sid: SocketId): F[Unit] = ref.update(_ + sid)
def unsubscribe(sid: SocketId): F[Unit] = ref.update(_ - sid)
}
这让我们可以访问当前连接数的离散流(subscriptions)——也就是说,每次变化都会输出当前集合大小。
继续 Handler 的实现,我们会先创建如下内部原语:
(
Deferred[F, Either[Throwable, Unit]],
Deferred[F, Unit],
Ref.of[F, Set[Symbol]](Set.empty)
).mapN { case (switch, fuze, subs) =>
???
}
switch: 用于同步 handler 的终止。fuze: 用于同步首次收到消息,避免漏掉订阅。subs: 跟踪已订阅的 symbol 集合。
还有一些块内的辅助函数,首先是用于编码出站消息的:
val toWsFrame: WsOut => WebSocketFrame =
out => Text(out.asJson.noSpaces)
val encode: WsOut => F[Option[WebSocketFrame]] =
case out @ WsOut.Notification(t: TradeAlert) =>
subs.get.map(_.find(_ === t.symbol).as(toWsFrame(out)))
case out =>
toWsFrame(out).some.pure[F].widen
收到带交易告警的 Notification 时,只有已订阅对应 symbol,才会向客户端发送。其他消息则无条件发送。
下一个辅助函数将 WS 消息解码为入站消息:
val decode: WebSocketFrame => Either[String, WsIn] =
case Close(_) => WsIn.Close.asRight
case Text(msg, _) => jsonDecode[WsIn](msg).leftMap(_.getMessage)
case e => s">>> [$sid] - Unexpected WS message: $e".asLeft
我们用 JSON 编码消息,格式需要和 API 的各个客户端约定好。为了避免兼容性破坏,建议为这些消息做 golden test 或回环转换测试。trading 项目里的 WsCodecSuite 就提供了相关覆盖。
接着是 send 的实现:
val attached =
Stream.eval {
conns.subscribe(sid) *> encode(WsOut.Attached(sid))
}
val onlineUsers =
conns.subscriptions.evalMap { n =>
encode(WsOut.OnlineUsers(n))
}
val send: Stream[F, WebSocketFrame] =
onlineUsers
.mergeHaltR {
attached ++ alerts.evalMap { x =>
fuze.get *> encode(x.wsOut)
}
}
.interruptWhen(switch)
.unNone
onlineUsers 流每次订阅数变化时都会发送一条消息,与 attached 和 alerts 流并发运行。
attached 先注册连接(subscribe),再发初始的 Attached 消息。alerts 则将每条告警编码为合适格式的 WebSocketFrame。
其中 fuze.get 只在首次运行时需要,用于防止订阅和发送告警之间的竞争条件。具体完成是在 receive 部分完成的。
回顾下,Pipe[F, I, O] 实际上就是 Stream[F, I] => Stream[F, O]。来看 receive 的实现:
val close = conns.unsubscribe(sid)
val receive: Pipe[F, WebSocketFrame, Unit] =
_.evalMap {
decode(_) match
case Left(e) =>
Logger[F].error(e)
case Right(WsIn.Heartbeat) =>
().pure[F]
case Right(WsIn.Close) =>
close *> switch.complete(().asRight).void
case Right(WsIn.Subscribe(symbol)) =>
subs.update(_ + symbol)
case Right(WsIn.Unsubscribe(symbol)) =>
subs.update(_ - symbol)
}.onFinalize {
Logger[F].info(s"[$sid] - WS connection terminated") *> close
}.onFirstMessage(fuze.complete(()).void)
它接收 WebSocketFrame 消息并尝试解码为 WsIn。第一种情况(Left(e))是解码失败,仅记录日志并继续处理下一个消息。
这里我们没有返回负向确认(其实用的是自动确认),因为 alerts 属于新消息会覆盖旧消息的场景,后续再处理老消息反而会产生错误。
Heartbeat 只对底层连接有意义,直接忽略。
Close 消息表示连接已优雅关闭,这时会取消订阅并 complete switch,从而中断 send 流。
Subscribe/Unsubscribe 则更新内部订阅集合。
收到第一条消息时会 complete fuze,确保 send 和 receive 同步。具体做法如下自定义扩展方法:
extension [F[_], A](src: Stream[F, A])
/* Perform an action when we get the first message without consuming it twice */
def onFirstMessage(action: F[Unit]): Stream[F, A] =
src.pull.uncons.flatMap {
case Some((chunk, tl)) =>
Pull.eval(action) >> Pull.output(chunk) >> tl.pull.echo
case None => Pull.done
}.stream
实现的复杂性,主要体现在底层 WS 连接不可避免的竞态条件上。虽然整体代码力求简洁,但在处理流中断等场景时不太容易自定义。
6.4.4 单元测试(Unit tests)
对如此复杂的实现进行测试,本身就需要花点心思。我们有一个名为 conns 的伪造 WsConnections 实现,以及以下几个原始组件:
test("WS message handler") {
(
GenUUID[IO].make[SocketId],
IO.ref(List.empty[WebSocketFrame]),
IO.deferred[Unit],
IO.deferred[Either[Throwable, Unit]]
).tupled.flatMap { (sid, out, switch, connected) => ??? }
}
sid:唯一 socket 标识out:所有将要发送的 WsOut 消息switch:用于同步发送 WsIn.Close 消息connected:用于感知是否有活跃订阅
测试主体如下:
Handler.make(sid, conns, Stream.emits(alerts)).flatMap { h =>
val recv =
Stream.emits(input).append {
Stream.eval(switch.get.as(Text(WsIn.Close.asJson.noSpaces)))
}.through(h.receive)
.void
val send =
h.send
.evalMap(wsf => out.update(_ :+ wsf))
.onFinalize(switch.complete(()).void)
Stream(recv, send).parJoin(2).compile.drain
} >> out.get.flatMap {
case Text(x, _) :: Text(y, _) :: xs =>
NonEmptyList.of(
expect((x + y).contains("Attached")),
expect((x + y).contains("OnlineUsers")),
expect.same(xs.size, alerts.size - 1)
)
.reduce
.pure[IO]
case _ =>
out.get
.flatMap(_.traverse_(IO.println))
.as(failure("expected non-empty list"))
}
recv 流会发送一系列有限数量的 WsIn 消息,模拟客户端与服务端的交互,并通过 handler 的 receive 方法处理它们。
val input = List(
WsIn.Subscribe(sl1),
WsIn.Heartbeat,
WsIn.Unsubscribe(sl2),
WsIn.Subscribe(sl1),
WsIn.Heartbeat
).map(in => Text(in.asJson.noSpaces))
这些消息一旦发送完成,就会等待 switch 被 complete,随后发送 WsIn.Close 消息。
而 send 流则消费 handler 的 send 方法产生的消息,不断更新收到的 WsOut 消息列表。
当流结束时,switch 会被 complete,从而触发整个流的优雅终止。
最后,我们获取 out 的内容,并根据实际收到的 Attached、OnlineUsers 消息,以及订阅测试实现产生的告警数,做一系列断言:
val alerts = List(
Alert.TradeAlert(id, cid, AlertType.Buy, sl1, p1, p1, p1, p1, ts),
Alert.TradeUpdate(id, cid, TradingStatus.Off, ts),
Alert.TradeAlert(id, cid, AlertType.Sell, sl1, p1, p1, p1, p1, ts),
Alert.TradeUpdate(id, cid, TradingStatus.On, ts),
Alert.TradeAlert(id, cid, AlertType.StrongSell, sl1, p1, p1, p1, p1, ts),
Alert.TradeAlert(id, cid, AlertType.Neutral, sl2, p1, p1, p1, p1, ts)
)
注意,第一条消息既可能是 Attached,也可能是 OnlineUsers,因为这两条流是并发运行的,所以断言采用了 (x + y).contains("Attached")。
6.4.5 入口(Entry point)
这个服务的做法有些不同,因此我们要做深入分析。
首先,我们有一个函数,接收 SocketId,构建一个非持久化(non-durable)且独占(exclusive)的 Pulsar 订阅。非持久化模式代表一种轻量级订阅,不会有持久化游标。
val mkSub = (sid: SocketId) =>
Subscription.Builder
.withName(s"ws-server-${sid.show}")
.withMode(Subscription.Mode.NonDurable)
.withType(Subscription.Type.Exclusive)
.build
这意味着我们会为每个打开的 socket 创建一个消费者(也就是临时 topic,ephemeral topic),稍后还会详细讨论。
接着是启用消费者读取 compacted topic 的设置:
val compact =
PulsarConsumer
.Settings[IO, Alert]()
.withInitialPosition(SubscriptionInitialPosition.Earliest)
.withReadCompacted
.some
我们还把初始位置设置为 Earliest,这样可以像事件溯源那样,从头拿到所有告警。因为 topic 是 compacted,我们总能拿到每个 symbol 的最新告警,不会影响启动速度。
然后是依次获取各种资源:
def resources =
for
config <- Resource.eval(Config.load[IO])
pulsar <- Pulsar.make[IO](config.pulsar.url)
_ <- Resource.eval(Logger[IO].info("Initializing ws-server service"))
ptopic = AppTopic.Alerts.make(config.pulsar)
conns <- Resource.eval(WsConnections.make[IO])
mkConsumer = (sid: SocketId) => Consumer.pulsar[IO, Alert](
pulsar, ptopic, mkSub(sid), compact
)
mkAlerts = (sid: SocketId) => Stream.resource(mkConsumer(sid)).flatMap(_.receive)
mkHandler = (sid: SocketId) => Handler.make[IO](sid, conns, mkAlerts(sid))
server = Ember.websocket[IO](config.httpPort, WsRoutes[IO](_, mkHandler).routes)
yield conns -> server
如前所述,我们用的是 consumer 的 receive,自动进行消息确认(详见 Events handler)。
与前面服务不同,这里我们用的是 Ember.websocket,这是 core 模块下自定义的构造器:
def websocket[F[_]: Async: Console](
port: Port,
f: WebSocketBuilder[F] => HttpRoutes[F]
): Resource[F, Server] =
metrics[F].flatMap { mid =>
make[F](port)
.withHttpWebSocketApp { ws =>
mid(f(ws) <+> HealthRoutes[F].routes).orNotFound
}
.build
.evalTap(showBanner[F])
}
实现细节不再赘述,关键区别是这里用的是 withHttpWebSocketApp,而不是经典的 withHttpApp。
6.4.6 运行(Run)
最后,是 ws 应用的运行方式:
def run: IO[Unit] =
Stream
.resource(resources)
.flatMap { (conns, server) =>
Stream.eval(server.useForever).concurrently {
conns.subscriptions.evalMap { n =>
Logger[IO].info(s"WS connections: $n")
}
}
}
.compile
.drain
我们在后台持续运行 HTTP 服务器,同时并发地记录当前 WebSocket 连接数——每当有客户端注册或注销时,都会触发日志输出。
图 6.13 展示了由该服务生成的三个不同的消费者(暂时忽略另外一个)。每个消费者都以独占(exclusive)模式运行,并且具有与 SocketId 对应的唯一订阅名称。
6.4.7 可扩展性(Scalability)
可扩展性也是该服务的重要方面。在图 6.14 所示的示例中,我们可以看到有两个 ws 实例并发运行,它们各自独占地订阅 trading-alerts 主题。
这种设计支持水平扩展,使我们能够根据高峰期的流量需求启动任意数量的实例,而在流量较低时减少资源消耗,从而节约成本。
在大规模场景下,单一的 trading-alerts 主题可能成为潜在的瓶颈。为了提升吞吐量,下一步可以使用分区主题(partitioned topic),让多个 broker 共同服务该主题。
一种可行的做法是从一个单分区的分区主题开始,这样未来扩展起来更简单。
如图 6.14 所示,每个消费者唯一对应一个由 SocketId 标识的 WS 客户端。
我们将订阅设置为非持久化(non-durable),因为目前还没有让客户端断线后重连并继续之前状态的需求。这意味着客户端断开连接后,订阅会被计划删除,从而允许我们放心地动态创建临时主题(ephemeral topics)。
6.4.8 附录(Addendum)
为了说明一开始的设计并非完美无缺——代码也在不断演进——这里分享一个有趣的设计变更故事,我觉得其中的教训很有价值。
最初,这个服务只有一个 alerts 消费者,将告警广播到一个内部 Topic(来自 fs2.concurrent)。这种方式暂时足够用,也相对简单。
相关代码如下(假设 topic 类型是 Topic[F, Alert]):
consumer.receiveM.evalMap { case Consumer.Msg(id, _, alert) =>
topic.publish1(alert) *> consumer.ack(id)
}
然后,Handler 实例会订阅这个内部 topic,而不是直接从 Pulsar 消费告警:
topic.subscribe(100) // Stream[F, Alert]
问题出现在我开始考虑重复告警时。生产者端告警做了去重,这本身没问题。
但问题是依赖内部 topic,而非 Pulsar,这样就丧失了 broker 对去重的保证。
根本原因就在于这两个操作:
topic.publish1(alert) *> consumer.ack(id)
可能会出现一种情况,我们广播了告警给所有内部 topic 订阅者,但确认消息(ack)失败。此时 Pulsar 会安排重新投递这条消息。
这样就会产生重复告警,必须额外处理,固有地增加了服务设计和维护的复杂度。
因此,舍弃内部 topic,将重复消息处理责任交给 Pulsar,极大简化了实现。
这也就是为什么采用“每个 socket 一个消费者”的方案。你可以在这个 pull request 中看到对应的最初改动。
6.5 小结
到目前为止,我们已经了解了能够实现第一个里程碑的三个核心服务。但前方仍有大量工作等待完成。
我们讨论了关键的设计决策,并了解了每个服务的能力,包括它们如何应对可扩展性挑战。
在接下来的两章中,我们将学习剩余的服务以及分布式系统中的一些重要特性,如监控和可观测性。