凌晨两点十三分,某券商的夜盘清算系统挂了。
不是那种慢慢变慢然后超时的挂法,是突然间所有下游服务全部拿不到当日成交汇总数据,风控模块开始疯狂报空指针。运维团队排查了将近四十分钟才发现,上游的订单服务在做数据库主从切换的时候丢了一批写入,而那批写入对应的成交记录已经通过消息队列推送给了清算和风控两个下游,下游拿着这些「幽灵成交」去做资金核算,账怎么都对不上。
说实话,这种事故在金融行业里算不上罕见。你想想看,一笔交易从下单到成交再到清算,中间要经过四五个系统,每个系统之间要么是HTTP同步调用,要么是靠一张数据库表做中转,数据一致性全靠人肉保证。出了事只能对着日志一行一行捞,捞到天亮也不一定能搞清楚到底哪个环节先出的问题。
那次事故之后,他们的架构团队花了大概半年时间重新梳理了整个交易链路的数据流,最后落地了一套基于事件溯源的架构。这篇文章就是我根据那个案例整理的选型思路和工程细节,中间走了不少弯路,也踩了一些当时完全没预料到的坑。
请求-响应模式在交易场景里为什么撑不住
大部分金融系统的早期架构长得都差不多,经典的三层结构,Controller调Service,Service调DAO,中间穿插一些RPC调用跟外部系统对接。这套东西在日均几万笔交易的体量下没什么问题,但一旦量上来,毛病就全暴露了。
第一个痛点是数据同步的延迟和丢失。订单服务把成交结果写进MySQL,然后发一条消息通知清算服务。这条消息什么时候发?写在事务里还是事务外?写在事务里,消息发了但事务回滚了怎么办?写在事务外,事务提交了但消息没发出去怎么办?这个经典问题在业界被翻来覆去讨论了很多年,各种trick都有人试过,本地消息表、事务消息、Binlog监听,各有各的代价。
第二个痛点是系统间的耦合。清算服务要知道订单服务的表结构才能做对账,风控服务要知道持仓服务的接口才能算敞口,一个字段改了三个系统要一起发版。这种紧耦合在迭代速度快的团队里简直是灾难,怎么说呢,每次发版都像在拆炸弹,你永远不知道哪个下游会因为你的一个字段变更而崩溃。
第三个痛点更隐蔽,状态的可追溯性。一笔订单从创建到最终结算,中间经历了哪些状态变迁?大部分系统只保存了「当前状态」,你想知道这笔订单三小时前是什么状态,抱歉,查不到。除非你提前做了审计日志,但审计日志和主数据又是两套东西,一致性又要另外保证。
有个细节很多人忽略了,金融监管要求交易记录至少保存五年,而且要能做到「任意时间点回溯」。这不是简单的数据归档就能解决的,你需要的是在任何一个历史时刻都能重建当时的系统状态。
Kafka 能解决什么,不能解决什么
引入Kafka做事件总线,是大部分团队的第一反应。这个选择本身没毛病,Kafka确实解决了一部分问题。
消息持久化,Kafka天然支持。高吞吐,单机百万级TPS不在话下。消费者组机制可以让多个下游独立消费同一份数据,解耦做得很干净。Partition机制能保序,对于交易场景来说这一点很关键,同一笔订单的事件必须按顺序处理。
他们最初的做法是把订单状态变更都发成Kafka消息,topic按业务域划分,order-events、trade-events、settlement-events,下游各取所需。
|
|
跑了一段时间之后,几个问题慢慢浮出水面。
事件丢失的灰色地带。 上面那段代码看起来很正常对吧?但如果kafkaTemplate.send这一行抛了异常呢?数据库里的状态已经改了,消息没发出去。你可以加try-catch,可以在send之后做确认,但每种补救措施都引入了新的复杂度。这个问题归根结底是因为数据库和消息队列是两个独立的存储,你没有办法在一个事务里把它们一起搞定。
Schema演进是个噩梦。 半年后业务需求变了,订单事件里要加一个「佣金拆分」字段。老消息里没有这个字段,下游消费老消息的时候怎么处理?给默认值?那默认值在业务上是否正确?每次schema变更都要拉着所有下游团队一起评估,效率极低。
事件只描述了「发生了什么」,没有描述「为什么」。 你看到一条OrderCancelled事件,但不知道为什么取消。是客户主动撤单?是风控拦截?是超时自动取消?这些信息散落在各个服务的日志里,想拼起来非常费劲。
Kafka的消息保留策略和审计要求冲突。 Kafka的设计哲学是消息消费完就可以丢了,retention通常设7天到30天。但金融审计要求你能回放五年前某一天的全部事件流,这显然做不到。
其实吧,Kafka在这套架构里扮演的角色更像是一个「高效快递员」,它负责把消息从A送到B,但它不关心消息的含义,也不负责维护消息之间的因果关系。你需要的是一套更体系化的方法来管理事件,而不仅仅是一个消息中间件。
Event Sourcing 的核心想法,状态是事件的投影
Event Sourcing这个概念其实不新鲜,Martin Fowler在2005年就写过相关文章,但真正在金融系统里落地的案例并不多,大部分还停留在「我们知道这个概念但没真的用过」的阶段。
核心思路用一句话说清楚,你的系统状态不应该是直接存储的,而应该是通过回放一系列事件计算出来的。
拿银行账户举例。传统做法是在数据库里存一个balance字段,每次交易直接更新这个字段。Event Sourcing的做法是不存balance,而是存一连串的事件,开户存入1000、转出200、转入500、转出100,当前余额就是把这一串事件从头算一遍,1200。
你可能觉得这不是脱裤子放屁吗?直接存余额不好吗?确实,对于简单场景来说这就是多此一举。但在金融交易场景下,这套做法有几个非常大的优势。
天然的审计日志。 每一笔状态变更都有对应的事件记录,事件本身就是审计日志,不需要额外维护一套。监管来查的时候,直接把事件流拉出来就行,谁在什么时间做了什么操作,一目了然。
任意时间点回溯。 想知道昨天下午三点十五分某个账户的状态?把那个时间点之前的事件重放一遍就行了。这在传统架构下几乎不可能做到,除非你在每个时间点都做了快照。
事件的因果链完整。 每个事件都包含了完整的业务上下文,为什么发生、触发了什么、结果是什么,全部记录在案。排查问题的时候顺着事件流看一遍就能还原现场,不用再对着四五个系统的日志来回切换。
但Event Sourcing也有明显的代价,这一点很多布道者不会告诉你。
| 维度 | 传统CRUD架构 | Event Sourcing |
|---|---|---|
| 读性能 | 直接查表,O(1) | 需要重放事件或读投影,取决于投影的实时性 |
| 写性能 | 单行UPDATE | 追加写入,顺序IO,反而可能更快 |
| Schema演进 | 改表结构+数据迁移 | 需要处理多版本事件的兼容 |
| 调试难度 | 看当前状态 | 需要理解事件流,心智负担更重 |
| 团队学习成本 | 几乎为零 | 相当高,大部分人没有相关经验 |
| 查询灵活性 | SQL随便查 | 必须通过预定义的投影来查询 |
金融场景下那些绕不过去的硬约束
金融行业和普通互联网业务最大的区别,不在于并发量有多高,而在于监管对数据处理有一套非常严格的要求。这些要求直接影响架构选型,你必须先把这些约束搞清楚,否则方案做得再漂亮也过不了合规那一关。
审计追溯的强制性要求
国内的《证券期货业信息安全事件报告与调查处理办法》明确规定了交易数据的保存期限和可追溯性要求。欧盟那边的MiFID II更狠,要求投资公司保留完整的订单生命周期记录,精确到微秒级别,而且要能在监管要求时快速提供。
这就意味着你的事件存储必须满足几个条件,事件一旦写入不可修改(append-only),事件必须带有时序标记且时戳来源可信,事件流必须能在合理时间内完成全量回放。
数据不可篡改
这个要求听起来简单,实现起来有很多细节。你的事件存储如果就是一个普通数据库,那DBA有权限直接改数据,这在审计层面就不合规。有些团队会引入区块链或者类似Merkle Tree的机制来保证事件链的完整性,但说实话大部分场景下没走到这一步,靠权限管控加加密签名就够了。
幂等性和Exactly-Once
金融交易最怕两件事,重复扣款和漏扣款。在事件驱动架构里,这对应的是事件的幂等消费和Exactly-Once语义。Kafka从0.11版本开始支持Exactly-Once Semantics,但这个EOS有很多限制条件,并不是说开了这个配置就万事大吉了。
|
|
说真的,Exactly-Once在分布式系统里是一个被严重低估了难度的问题。你在论文里看到的各种优雅方案,到了工程落地的时候全都要打折。最后大部分团队的策略是,接受At-Least-Once,但在消费端做严格的幂等控制。这个方案虽然不够「纯粹」,但工程上可行,成本也可控。
选型决策的过程和结论
他们的架构团队在选型阶段花了大概两个月,把市面上能找到的方案都过了一遍。最终的选择是把两者结合起来,Kafka做事件传输层,Event Sourcing做事件存储和状态管理。
下面这张表是他们当时做的方案对比,我做了些简化。
| 方案 | 吞吐能力 | 事件追溯 | 状态重建 | 团队成本 | 合规适配 |
|---|---|---|---|---|---|
| 纯Kafka + CRUD | 很高 | 受限于retention策略 | 不支持 | 低 | 需额外审计系统 |
| 纯Event Sourcing(无Kafka) | 中等 | 完整支持 | 完整支持 | 高 | 天然适配 |
| Kafka + Event Sourcing | 高 | 完整支持 | 完整支持 | 较高 | 天然适配 |
| Kafka + CDC(Binlog) | 高 | 部分支持 | 有限支持 | 中等 | 需额外适配 |
最终选择第三种方案的理由很务实。Kafka的高吞吐和消费者组机制解决了事件分发的问题,Event Sourcing的append-only事件存储解决了审计和回溯的问题,两者各司其职。
但你想想看,两套系统维护同一份事件数据,一致性怎么保证?这就是落地过程中最头疼的部分。
几个关键的工程决策
方案定了之后,真正难的是落地。有些决策在架构图上看不出来,但写代码的时候每一个都绕不过去。
事件存储用什么
这个问题争论了很久。有人提议直接用Kafka做事件存储,把retention设成永久。这个方案被否了,原因有几个,Kafka不是为长期存储设计的,无限增长的topic会导致partition rebalance异常缓慢,运维复杂度暴增。
有人提议用EventStoreDB,这是专门为Event Sourcing设计的数据库,支持事件流、快照、投影,功能非常对口。但团队评估之后也放弃了,主要是团队里没人用过,运维能力跟不上,出了问题找不到人。
最后选了一个比较折中的方案,用PostgreSQL做事件存储。PostgreSQL的JSONB类型存事件体绰绰有余,表分区按月划分方便归档,append-only的写入模式对PostgreSQL来说也很友好。你敢信,最后扛住日均两千万事件的居然就是一个PostgreSQL。
事件表的结构大致长这样,
|
|
投影怎么建
Event Sourcing里最让人头疼的就是读端的处理。你不能每次都从事件流里重算当前状态,所以必须维护「投影」,也就是从事件流计算出来的物化视图。
他们建了好几个投影,订单当前状态投影存在PostgreSQL里供后台管理界面查询,实时持仓投影存在Redis里供风控引擎读取,交易统计投影存在ClickHouse里供报表系统查询。
|
|
投影构建器本身就是Kafka消费者,消费event-store这个topic,然后根据事件类型更新对应的投影存储。这里有一个关键的设计决策,投影的构建允许有短暂延迟,但必须有明确的延迟监控。如果投影延迟超过阈值,就要触发告警。
事件版本化策略
这个是他们踩坑最多的地方。业务发展快,事件结构隔三差五就要改,版本化策略如果没设计好,后面改起来极其痛苦。
他们试过几种方案。
方案A,Weak Schema。 事件体用JSON,加新字段的时候下游做兼容处理,遇到不认识的字段就忽略。这个方案初期很好用,但后来出了一个大坑,某个下游把一个新加的必填字段当成可选处理了,导致清算金额算错,直到月底对账才发现。
方案B,Upcasting。 老事件在读取的时候自动升级到最新版本。这个方案在Greg Young的书里被推荐过,实现起来需要在事件存储和消费者之间加一层Upcaster,把老格式转换成新格式。好处是消费者永远只处理最新版本,坏处是Upcaster本身会变得越来越复杂,而且转换过程中的bug很难被发现。
方案C,也就是他们最终采用的,事件演化(Event Evolution)。 不修改老事件的结构,而是引入新的事件类型。比如原来的OrderCreated事件不够用了,不修改它,而是新增一个OrderCreatedV2事件。老事件的处理逻辑保留,新事件走新逻辑。这个方案的好处是历史事件的语义永远不会变,坏处是事件类型会越来越多,需要有专门的文档来记录演化历史。
反正我觉得方案C是最务实的选择,虽然看起来有点笨重,但在一个需要运行五年十年的金融系统里,「笨重但可控」远比「聪明但脆弱」要好。
性能数据和踩坑记录
系统上线之后跑了大概三个月,积累了一些值得记录的数据和经验。
吞吐和延迟
日均事件量在1800万到2200万之间,峰值出现在开盘后十五分钟和收盘前半小时,能到日均值的三倍左右。Kafka集群用了6个broker,每个topic 12个partition,整体CPU使用率稳定在40%上下。
事件写入延迟,从业务代码发出事件到Kafka broker确认,P99在3毫秒以内。投影构建延迟,从事件写入到投影更新完成,P99在120毫秒左右,这个延迟对于风控引擎来说可以接受,因为他们对持仓数据的要求是秒级实时。
状态重放的延迟是一个比较有意思指标。一个包含5000个事件的订单聚合根,完整重放一次大概需要15毫秒。他们引入了快照机制,每100个事件做一次快照,重放的时候先从最近的快照加载,再回放之后的增量事件,这样大部分场景下重放时间可以控制在2毫秒以内。
踩过的坑
坑一,事件风暴。 有一次某个批量任务触发了大量关联事件,一个订单取消事件级联触发了十几个关联订单的状态变更,这些变更又各自触发了新的事件,最终在十秒内生成了三十多万条事件。Kafka倒是扛住了,但投影构建器被打爆了,Redis的连接池被打满,风控引擎拿到的持仓数据延迟飙到了五秒以上。
后来引入了事件风暴检测机制,如果单个聚合根在短时间内产生的事件数超过阈值,就自动降级为批量处理模式,合并事件后再更新投影。
坑二,时区问题。 这个说出来你可能不信,他们的交易涉及多个市场,事件的created_at字段用的是UTC,但投影构建器在按日期聚合的时候用的是本地时区,导致跨日交易被归到了错误的日期。这个bug在测试环境完全复现不出来,因为测试的时候大家都在同一个时区里操作。上线后跑了两周才在对账的时候发现,修起来倒不复杂,但排查花了整整一天。
坑三,事件体的膨胀。 最开始设计事件体的时候,大家图方便把整个聚合根的状态都塞进了事件体里。结果一个订单事件体动辄好几KB,一天两千万事件算下来光事件存储就要几十个GB。后来改成只存增量变更,事件体平均大小降到了200字节左右,存储压力一下就下来了。但这样做的代价是重放逻辑变复杂了,你不能直接反序列化事件体得到完整状态,需要一步一步应用增量变更。
一个经验教训,在设计事件体的时候就要想清楚存储成本。金融系统的事件量增长速度远超你的预期,今天看起来无所谓的数据冗余,半年后就会成为存储和网络的瓶颈。
坑四,分布式事务的幽灵。 事件存储和投影存储是两个独立的数据库,极端情况下事件写成功了但投影更新失败。这时候你有一个事件没有反映在投影里,查询出来的状态就是错的。他们的做法是在投影构建器里加了事件序号连续性校验,如果发现序号有跳跃就暂停消费并告警,人工介入排查。这套机制上线后确实抓到过几次投影写入失败的情况,算是最后一道防线。
这套架构适合谁
说实话,Event Sourcing不是万金油,大部分业务系统根本不需要它。如果你的系统不需要完整的历史回溯,不需要审计追溯,状态变更频率不高,那CRUD加消息队列完全够用了,别给自己找麻烦。
但对于金融交易这类场景,Event Sourcing的优势是实打实的。监管要求你有完整的审计链,你就得有。业务要求你能回溯到任意历史时刻,你就得能。这些需求用传统架构来实现的成本并不比Event Sourcing低,而且更容易出错。
选型这件事吧,没有绝对正确的答案,只有当前约束下最不坏的选择。他们的团队最后能跑通,一方面是因为技术选型本身比较务实,没有追求理论上的完美,另一方面是因为管理层给了足够的时间来做基础设施的打磨,没有催着三个月上线。
那天晚上清算系统跑完最后一批对账数据的时候,运维群里有人发了句「终于平了」。群里安静了大概十秒钟,然后冒出来一排竖大拇指的表情。窗外天已经蒙蒙亮了,楼下便利店的灯亮了起来,有人起身说了句「我去买咖啡,谁要带一杯」。