千秋雪下埋藏的居然是一个技术复盘
本文内容基于真实事件虚构
1.引子
那个叫千秋雪的会议室有独立空调,在没有中央空调的周末,小王一头钻了进去。一个水杯,一台电脑,除此以外就是键盘的敲击声。Redis中的键值对在空中若隐若现,随着过期时间的步步逼近也模糊到消失。一个个事件被消息队列吞下或者吐出,或是冻结了时间后延迟被消费,飞舞的事件慢慢被有序的代码所限制,一个个队列也变得井然有序。
2.突然的Owner
做Owner嘛,最重要的是开心。
这次需求的内容是在某个国际化市场上推出网约车接单慢必赔的营销活动,类似前段时间在上海广州等地推出的X分钟司机未应答,乘客获得一张40元免单券的活动。
小王作为相关方参与了需求的开发,在仅剩2周开发时间的时候,活动运营系统的同事提出建议:让小王来担任项目的RD Owner,原因是小王负责的模块有最多的改动量。经过内部讨论后,小王接下了这个owner,这也意味着小王在承担最多改动量的同时,需要协调沟通七八个相关上下游团队的研发人员。
需要开发的模块叫做任务系统,是一个大约两年没有新需求开发过的旧服务。其位置处于整个后端链路的中间,上接客户端-BFF-API,下接活动运营系统,内部的主要逻辑有判定达成等待时间和下发端侧个性化文案,也是慢必赔活动核心的活动策略和用户感知,所以小王作为owner来协调整个需求也算是合适。
3.CQRS用还是不用?
这是个问题。
3.1 接手的服务有不少Bad Case
在接手这个两年没有人开发的服务后,查看线上日志,发现了一个叫做“改派消费延迟”的错误日志量占比较高,分析业务逻辑之后,报错的原因是:消费MQ延迟导致新订单无法查询到对应活动信息。

当一个订单被司机接单后,如果司机选择了取消本次行程,为了乘客的打车体验,系统内部会自动为乘客重新发出一个订单。
这个新的订单有着新的订单号,这意味着之前慢必赔活动的订单号需要和这个新的订单进行关联,这样乘客本次出行需求参与的慢必赔活动不需要重新开始计时。
在任务系统内,原来的设计比较好地隔离开了对数据的查询与变更,通过消费异步MQ消息来关联新的订单号,提供给外部的查询接口不会对数据产生任何变更。在这种模式下,无论端上请求如何重试,查询到的数据都是天然幂等的。
但是由于MQ的异步特性,客户端通过B通路获取活动信息的时候,任务系统还没有从A通路消费并且处理完成订单号关联的逻辑,这会导致极大降低对改派后用户对慢必赔活动的感知,从而影响营销活动最终的整体效果。
3.2 什么是CQRS?
CQRS,即”Command Query Responsibility Segregation”,用于分离一个应用程序的读取操作(查询)和写入操作(命令)。它的核心思想是将数据的读取和写入分开处理,以提高系统的可伸缩性、性能和可维护性。
- 命令和查询分离:CQRS将应用程序的读取操作(查询)和写入操作(命令)分开处理。这意味着在架构中会有两个不同的模型:一个用于处理命令,另一个用于处理查询。
- 命令模型:命令模型处理系统中的写入操作,例如创建、更新或删除数据。这些操作会触发逻辑执行,并且通常不返回结果,或者只返回基本的确认信息。
- 查询模型:查询模型用于处理系统中的读取操作,例如获取数据以供展示。查询模型通常支持高效的数据检索,并返回易于呈现的数据结构。
- 松耦合性:CQRS通过明确分离命令和查询模型实现系统中的松耦合性。
- 事件驱动: CQRS通常与事件驱动架构一起使用,其中命令的执行和状态更改会触发事件,这些事件可以通知其他部分或外部系统。这有助于实现实时反馈和异步通信。
在上面的例子中,“命令”是异步消息队列中对订单号的关联,“查询”是客户端获取是否有慢必赔活动信息。
由于特定的业务逻辑,适合大部分不要求极高一致性场景的CQRS模式似乎难以解决上面的问题。
3.3 一个“糟糕”的改造方案
在查看客户端请求的字段后,小王发现其中已经包含了足够判断是否“改派订单”的信息了。于是,在重构新版本慢必赔活动时,直接放弃了对原来消息队列的消费逻辑。同时,在查询接口中,改变了查询接口的语义,将关联新的订单号逻辑放在了查询接口中去实现。
3.4 查询请求的幂等性改造
在进行完上一步中描述的改造之后,负责测试的同学提出在出现改派后慢必赔的活动不能正常展示的BUG。小王感觉很奇怪,在线上捞了一个trace查看,发现接口可以正常返回。于是要求QA多提供几个trace后终于发现了一些端倪。
客户端的请求因为一些页面切换重试逻辑,会发送两次到三次同样的请求,并且会以最后一次的结果来更新端上的界面。之前发现正常返回结果的trace是第一次请求,而后续的所有请求都失败了。
老逻辑消费消息队列时,即便收到了多次同样的请求,后面的请求会出现订单号校验错误,直接结束处理流程。而查询接口不会改变任何系统内的数据,同样也不会发生多次请求结果不一致的问题。
为了在客户端不修改请求数据的情况下解决这个问题,我们需要结合业务逻辑思考。当多次请求时,只要判断订单号在这一次用户叫车需求的历史订单列表中,就认为通过了请求校验,从而满足请求幂等性。

4.不要相信你的上下游
即便经常听老板说,但只有遇到足够多的Case后才能理解。
4.1 活动营销系统的接口语义
这次的新慢必赔活动配置在一套新的活动系统配置后台上操作,对于任务系统来说,需要接入新版接口来判定当前的乘客是否可以参与慢必赔活动,来决策接口返回的内容。
负责测试的同学又提了一个BUG:慢必赔活动命中不稳定,时有时无。
经过一番排查后,定位到了问题:客户端上由于某些原因,请求活动的接口会快速调用多次。对于任务系统来说,从活动系统获取到了对应慢必赔活动信息存储到缓存后就不会再进行请求。但是如果端上的请求间隔很短,第一次请求返回的数据还没有写入缓存,就会导致第二次请求再次去调用活动系统。

由于一些接口沟通与历史遗留问题,小王在最初设计方案时并没有意识到新的活动判定接口对同样的请求只有第一次会返回命中活动,同时也没有意识到客户端在第一次请求判断乘客是否能参与慢必赔活动的时候会有多次重复请求(和上面3.4节中的问题类似)。
一个比较显然的方式是通过SingleFlight模式去避免重复请求下游。
4.1.1 什么是SingleFlight
singleflight是Go语言标准库中的一个包,它提供了一种防止重复执行相同函数的机制,即使在并发环境下也能确保只执行一次。它的核心原理是使用了一个用于协调并发请求的锁和缓存,以避免多个 goroutine 同时执行相同的函数。
Group组织请求:
singleflight使用一个Group结构来组织并发请求。每个请求都与一个唯一的字符串键相关联,这个键用于标识请求是否已经在执行中。互斥锁:对于每个键,
Group使用互斥锁来确保只有一个 goroutine 可以进入关联的函数执行部分。如果多个 goroutine 同时请求相同的键,只有一个能够执行,其他的会等待。结果缓存:
Group使用一个结果缓存来存储已经执行过的函数的结果。这个缓存是一个map,其中键是请求的键,值是函数的执行结果。一旦函数执行完成,它的结果就会被缓存起来,以供后续相同键的请求使用。等待通知:如果多个 goroutine 同时请求相同的键,除了一个能够执行外,其他的 goroutine 会被阻塞,等待执行完成的 goroutine 通知它们。这样可以避免多次执行相同的函数。
结果合并:如果多个 goroutine 请求相同的键,但是执行的函数不是相同的,
singleflight会等待第一个请求的函数执行完成后,将结果返回给所有等待的请求。这可以确保多个请求都得到了执行结果。
singleflight 通过互斥锁、结果缓存和等待通知的机制,有效地防止了多个 goroutine 同时执行相同函数的问题,并且能够高效地处理多个请求的情况。这对于需要避免重复执行昂贵操作的应用场景非常有用。要使用 singleflight,只需创建一个 Group,然后使用其 Do 方法来执行函数,该方法会自动处理并发请求。
4.1.2 使用SingleFlight模式的危险性
SingleFlight模式看起来解决了客户端多次重复请求的问题,如果我们按照这个模式进行实现,在测试环境的单容器节点模式下也不会测试出任何问题。但是一旦上线后,在分布式环境下,由于客户端的请求会被路由到不同的节点进行处理,在计算情况下,每个请求发送到不同的容器节点,效果和未使用SingleFlight是类似的。

4.1.3 添加分布式锁
为了彻底解决这个问题,通过对每个请求的乘客ID+订单ID添加分布式锁,保证最多请求一次下游的活动系统。
关于分布式锁的文章很多,这里就不再赘述了。
4.2 客户端上报字段类型版本差异
在整个需求中,我们需要消费一些乘客与司机的行为事件,用于进行一些业务逻辑处理。
而我们发现,在不同的环境,消费到的数据字段类型有差异,同一个字段在测试环境与生产环境可能分别是int和string。
后来咨询得知,不同版本客户端会上报不同的字段,而业务层部分服务是PHP开发的,并不会对类型进行强校验。在golang的服务中,如果完整解析整个结构体,就会因为各类字段类型问题导致无法解析。
解决方案也比较简单,删除对多余字段的解析,只要可以编译通过,那就说明没有删除正在使用的字段。当然,也可以通过类似gjson这样的库,去指定一个路径读取到对应的json字段。
4.3 上游接口兜底逻辑字段类型异常
同时,我们还发现了一些上游请求内容的解析异常,也就是同一个接口,同一个字段,上游使用的字段类型会改变。
沟通之后,发现是因为上游在获取不到真实数据的某个兜底流程中,使用了一些兜底数据,而这些数据的类型和真实的数据可能在不断迭代中已经产生了差异。
解决方案是在上游直接进行拦截。这种需要兜底的数据,本身字段也无法获取完全,所以可以直接不进行请求。
5.尾声
尘埃落定,小王走出了千秋雪会议室,打开网约车软件打车回家。
选择终点,点击“确认呼叫”,弹出了一个熟悉又陌生的弹窗:“发单慢必赔,5分钟没车就免单”。
参考资料
- https://kislayverma.com/software-architecture/architecture-pattern-cqrs/
- https://www.techtarget.com/whatis/definition/idempotence
- https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf
- https://pkg.go.dev/golang.org/x/sync/singleflight
- https://blog.devgenius.io/achieving-efficiency-with-singleflight-in-go-4daf668e48d