最近在读一个存储系统的代码的时候,产生了一些疑惑。背景是,这个存储系统对于一些重试的请求处理的比较敏感,(应该是写数据的系统都会敏感一些,因为要避免数据写错了),所以就涉及到一些RPC重试的语意的问题。
一个最基本的问题是,当我们发出一个RPC的时候,预期对端作出的行为是什么:
* 如果RPC超时了,对端可能执行了rpc,也可能没有执行rpc
* 如果我们不做重试的话,对端是否只会执行一次rpc呢
* 如果我们做了重试的话,对端的行为预期是什么样的,会过滤掉重试的请求吗
* 如果重试的response和之前的response同时返回了,我们希望处理那个response呢
首先考虑架在TCP/IP之上的RPC服务,有关用户态网络协议的,比如RDMA什么的暂时不考虑。
那么这里首先要考虑的是TCP层的重试语意,TCP层保证了这个链接下,每个byte对端的接收都是exactly once的——维护一个窗口来给上游一个连续的,不重不漏的字节流。
但是在真实的情况中,我们大概率不会只使用一个链接来发送请求。一般都会有链接复用的技术,所以其实依赖TCP层的去重基本上意义不大。
并且在一些存储系统中,如果发生宕机,还是需要考虑重试请求,那么这时候原本维护的链接的上下文已经丢失了,再次重试的链接已经是新的链接了,所以也无法保证exactly once
其实到这里,基本的结论就是,应用层不要依赖任何的假设(比如依赖RPC层不会重试等),对于要写入的数据,要保证能够处理重复请求的case,不受到RPC层的影响。
从client发送request是这个道理,其实client接收response也是一样的道理。不过这里按照直观的语意是,如果上次的请求失败了,进行了重试。
* 如果是rpc层做的重试,rpc层应该保证只有一个response被处理了。(比如第一个回来的人处理了)
* 如果是应用层的重试,rpc层应该会过滤掉上一次rpc返回的请求,只关注这一次的。
其实brpc处理上述逻辑也比较简单:
* 接收请求是bthread_id_lock -> process -> bthread_id_unlock_and_destroy
* 如果出现了重复的返回值,那么第二个请求的返回值会在bthread_id_lock的时候失败,因为上一个人已经destroy他了
因为我们不知道对端什么时候会返回请求,所以才会有了上面的设计。那么对于应用层来说:
* 如果是读请求的重复response,一般无所谓,返回任意一个就好了
* 对于写请求的response,因为上面说了tcp层保证了exactly once,所以要么是重复的request返回的,要么是对端的server重复的发送了response
* 我有点怀疑server端的回包会不会重试,不过就算会,其实处理也相当于执行了一次请求。
* 如果是重复的request导致的,那么可能第二次写入会有一些其他的报错,这时候就需要应用层去做处理了,比如发现是重复就认为已经成功,或者换一个位置继续写入,直到返回确定性的错误为止。
总结下来,作为rpc的使用者,任何时候应用层不要假设exactly once的语意,如果需要,那么自己保证。需要怀疑任何位置都有可能出现未知的重试。
* 不过对于掌控力比较强的框架,可以有一定的假设,比如在用brpc的时候,可以假设大概率,如果我不设置rpc的重试,那么执行一定是at-most-once的语意,不会出现client侧发一次,rpc层处理两次的情况。不过这种情况也需要兜底报错,只是说这种情况出现的概率极低(出bug什么的)
* 对于最少假设的原则来讲,只需要记住如下的规则:
* 如果收到了response,那么请求至少被执行了一次
* 如果没有收到response,那么请求可能被执行了(一次或多次),也可能没有被执行
* 更加强一点的则是考虑at-least-once和at-most-once:
* at-lease-once,如果收到了response,那么请求至少被执行了一次
* 一些请求有幂等语意的系统比较适合这种。比如brpc打开backup request就是这种case,第一个请求还没有返回的时候,可以用其他的链接再去发送一次请求。
* at-most-once,如果收到了response,那么请求至多被执行了一次
* 比如brpc/grpc的实现就是这种,重试的逻辑由用户负责,一次RPC只会通过一个链接发送一次,下层的重试会被TCP层过滤掉。所以只要client不发送第二次请求,那么server一定不会执行第二次。
文章评论