设计一个回调要注意哪些事情
回调是我们在设计系统的时候经常会使用到的, A服务调用B服务, 但是如果B服务提供的是一个较长时间的、异步的接口, 那么我们就会想到使用一个回调, 让B服务在异步处理结束之后, 来调用A的一个回调接口. 但是细品一下, 这一来一回的设计, 需要思考的点远不是一个回调接口这么简单.
回调是天生的并发
首先, 回调是一个天生的并发操作. 如果你的A服务在调用B服务等待回调的时候, 所有上下文都会保持原状, 只有回调才会修改, 那么是不会有并发问题的. 但是如果A服务在等待回调的过程中, 上下文会根据某个情况进行变化, 那么这里就有一个并发问题. 如果A的状态变化在回调之前? 如果A的状态变化在回调之后? 如果A的状态变化与回调同时进行?
前面两种, 如果状态变化和回调在前后, 那么唯一要注意的是需要做好防御编程. 状态的变更是有顺序的, 不应该让状态有任何跃迁或者回退行为. 否则很容易产生脏数据.
后面那种情况, 如果状态变化和回调同时进行, 这种情况其实是非常难处理的. 一般回调会是一个请求, 请求的链路是非常长的, 如何保证整个请求的原子性, 甚至于考虑日志的原子性, 是一个不小的挑战. 大致想了下, 可以有如下几个方法:
1 粒度比较大的锁, 这是通用性方法了. 将整个上下文锁住, 锁期间只有回调或者状态变化能进行操作. 当然, 如果不介意脏读的话, 完全可以使用读锁来保证读的高可用.
2 (读状态标记位 + 写状态标记位) + 防御编程. 需要保证这个状态标记位是原子性的, 这个还是比较好找的, 比如redis的某个key, mysql的某个字段. 但是需要保证读和写是一个原子行为. 当一个行为已经修改了状态标记位后, 另外一个行为会被防御编程拦下来.
3 队列化. 先使用队列, 将某个状态的变更都队列化, 然后异步一个个处理队列. 这个也是一个很好用的方法. 将所有的操作串行化自然能解决并发问题. 如果像go这样的天生协程的语言, 可以不依赖外部队列, 不妨开一个协程使用channel来进行串行化.
回调的超时时间
给一个回调设置超时时间, 这往往是个很难的事情. 它难的地方有两个: 第一, 被调用的B服务往往给不出这个时间. 既然是异步, B需要考虑的链路一定很长. 加之既然是服务间调用, 基本上你们两个服务会属于两个组织结构, 跨组织结构的沟通, 在所有公司都是一个不大不小的门槛. 第二, 超时之后的处理, 如果被调用方B服务给了一个超时时间, 那么A在超时时间之后要做些什么? B在超时时间之后还是否要发送回调呢? 这又涉及到了一个补刀机制.
但是反之思考, 如果不设置超时时间, 那么程序的健壮性又会是个很大的问题. 不管由于什么原因, 网络抖动, 程序bug, 回调丢失了. 被调用方B以为已经发了回调, 调用方A却没有收到回调. 这种不一致性会是一个更大的灾难, 它可能导致各种补刀策略失效.
所以还是建议需要给回调设置一个超时时间的, 至于超时后的处理, 则可以再定义一个机制进行补偿.
回调需要心跳么?
这也是一个很有意思的方案. 沿着回调设置超时时间的思路, 可能就有一种解决方案是我设置心跳是否可行. A调用B之后, 在等待B回调的过程中, B不断发送心跳给A, 告诉A我正在处理中, 一旦不发送心跳了. 那么就代表我死亡了.
首先这种就是一个有点悖论的方案. 这个心跳如果是从B到A, 那么为何不调整一个方向, 从A到B进行状态查询呢? 这种状态查询也可以充当心跳的功能. 再进一步, 既然都有了这种状态查询的心跳, 那为何还需要回调呢? 状态结束的时候, 这种状态查询心跳自然也就会检测到的. 当然这里可能唯一的差别就是实时性, 回调是一种结束即通知的机制, 心跳是一种定期得到通知的机制. 这又是另外一个需要考量的点了. 在异步绝大多数的场景下, 是可以容忍心跳时长的延迟的, 毕竟..都走异步化了, 多等一个心跳时间又有何妨呢?
回调的重试机制
被调用方B往往也是会知道回调的重要性, 所以一般会进行重试. 但是这种重试,如果不注意的话, 在有的时候, 就是杀死A服务的最后一根稻草.
其实就一点, 我们需要防止B服务的回调在短时间内堆积发送给A. 但是往往这种情况又是很可能发生的. 因为发生的原因很多, 比如B服务的队列堆积, 重启之后的疯狂发送. 又比如A服务的服务对接, 同一时间给B服务发送了很多任务, B服务的任务处理时长基本恒定, 导致同一时间一堆任务需要回调通知. 而这个时候, A服务如果在扛不住的情况下, 又会又导致很多回调失败, 触发回调重试机制.
当然, 这里的回调重试机制, 不是回调特有的, 而是重试机制特有的. 好的重试机制应该是散列的, 重试时长递增的. 这里可以参考TCP的慢启动机制.
能不用回调就不用回调
这个就是我整篇的观点, 能不用回调就不用回调, 因为回调要考虑的东西确实不少. 当然特定场景有特定的方法, 并不是所有场景都有并发,原子性的需求. 如果上述的理由还不够, 我想从业务架构层面再叨叨几个回调缺点.
回调使得链路变长且无向
我们最舒服的模块链路是A调用B再调用C. 但是一旦引入了回调, 就有可能A调用B,B回调A, A再调用B或者C, 如果频繁使用回调. 这个链路是一个很不舒服的链路. 即使服务只有少数几个, 也能让链路长度几何性增长. 并且最致命还是链路的无向性. A和B可以互相调用, 会导致分层非常不合理.
主导权丧失
对于一个任务, A是发起方, 但是A不是结束的发起方, 而是结束的被调用方,其实这就把主动权丢失一部分给B服务了. 业务逻辑就不闭合在A服务了. 这也算是一种主导权的丧失把.
服务耦合性增加
A调用B, B回调A, 这种设计就把A和B绑定在一起了. 耦合性增加的缺点一大堆, 这里就不赘述了.
总结
当然, 如果你看了上面的那么多回调的弊端, 还是在某个场景还是决定使用回调, 那么我相信, 这个场景一定有不得不用回调的原因. 瑾告诉, 慎用之. 因为, 我就是这么踩坑过来的...