前几天,去边锋网络上海分公司去面试,去了以后,说 PHP 负责人请假了,一个 Golang 后端面的,聊了2个非语言层面的问题,这里梳理总一下。

Q1:编写 API 接口,如果保证幂等性?

所谓幂等性设计,就是说,一次和多次请求某一个资源应该具有同样的副作用。用数学的语言来 表达就是:f(x) = f(f(x))。

举几个例子:

  • 订单创建接口,第一次调用超时了,然后调用方重试了一次。是否会多创建一笔订单?

因为系统超时,而调用户方重试一下,会给我们的系统带来不一致的副作用。

为什么会产生这样的问题,就是在我们把系统解耦隔离后,服务间的调用可能会有三个状态,一个是成功(Success),一个是失败(Failed),还有一个是超时(Timeout)。前两者都是明确的状态,而超时则是完全不知道是什么状态。

这种情况下,一般有两种处理方式:

  • 一种是下游服务提供查询接口,上游再请求超时后查询一下,如果查询到则表示成功,如果查询不到则表示失败。
  • 另一种是做接口幂等设计,也就是将查询动作放在下游服务,上游只管做重试,请求一次和请求多次结果是一样的。

对于第一种方式,需要对方提供一个查询接口来做配合。而第二种方式则需要下游的系统提供支持幂等性的交易接口。

要做到幂等性的交易接口,需要有一个唯一的标识,来标志交易是同一笔交易。而这个交易 ID 由谁来分配是一件比较头疼的事。因为这个标识要能做到全局唯一。

全局 ID

在全局唯一 ID 的算法中,这里介绍一个 Twitter 的开源项目 Snowflake。它是一个分布式 ID 的生成算法。其核心思想是,产生一个 long 型的 ID,其中:

  • 41bits 作为毫秒数。大概可以用 69.7 年。
  • 10bits 作为机器编号(5bits 是数据中心,5bits 的机器 ID),支持 1024 个实例。
  • 12bits 作为毫秒内的序列号。一毫秒可以生成 4096 个序号。

其他的像 Redis 或 MongoDB 的全局 ID 生成都和这个算法大同小异。我在这里就不多说了,提供一个掘金链接。 你可以根据实际情况加上业务的编号。

处理流程

对于幂等性的处理流程来说,说白了就是要过滤一下已经收到的交易。要做到这个事,我们需要一个存储来记录收到的交易。

于是,当收到交易请求的时候,我们就会到这个存储中去查询。如果查找到了,那么就不再做查询了,并把上次做的结果返回。如果没有查到,那么我们就记录下来。

uid

但是,上面这个流程是有一个可优化的细节,对于绝大请求应该都不会是重新发过来的,所以让所有请求都去查询一下这个不太合理。

所以,这里我们收到交易请求后,直接去存储记录这个 ID(相对于数据的 Insert 操作),如果出现 ID 冲突了的异常,那么我们就知道这个之前已经有人发过来了,所以就不用再做了。

HTTP 的幂等性

  1. HTTP GET 方法用于获取资源,不应有副作用,所以是幂等的。比如:GET http://www.bank.com/account/123456,不会改变资源的状态,不论调用一次还是 N 次 都没有副作用。

  2. HTTP HEAD 和 GET 本质是一样的,区别在于 HEAD 不含有呈现数据,而仅仅是 HTTP 头信 息,不应用有副作用,也是幂等的。

  3. HTTP OPTIONS 主要用于获取当前 URL 所支持的方法,所以也是幂等的。若请求成功,则它 会在 HTTP 头中包含一个名为“Allow”的头,值是所支持的方法,如“GET, POST”。

  4. HTTP DELETE 方法用于删除资源,有副作用,但它应该满足幂等性。比如:DELETE http://www.forum.com/article/4231,调用一次和 N 次对系统产生的副作用是相同 的,即删掉 ID 为 4231 的帖子。因此,调用者可以多次调用或刷新页面而不必担心引起错误。

  5. HTTP POST 方法用于创建资源,所对应的 URI 并非创建的资源本身,而是去执行创建动作的操 作者,有副作用,不满足幂等性。比如:POST http://www.forum.com/articles的语义 是在http://www.forum.com/articles下创建一篇帖子,HTTP 响应中应包含帖子的创建状态以及帖子的 URI。两次相同的 POST 请求会在服务器端创建两份资源,它们具有不同的 URI;所以,POST 方法不具备幂等性。

  6. HTTP PUT 方法用于创建或更新操作,所对应的 URI 是要创建或更新的资源本身,有副作用, 它应该满足幂等性。比如:PUT http://www.forum/articles/4231的语义是创建或更新 ID 为 4231 的帖子。对同一 URI 进行多次 PUT 的副作用和一次 PUT 是相同的;因此,PUT 方 法具有幂等性。

所以,对于 POST 的方式,很可能会出现多次提交的问题,对此的一般的幂等性的设计如下:

  • 其一,在表单中需要隐藏一个 token,这个 token 是后端生成的一个唯一的 ID。用于防止用户多次点击了表单提交按钮,而导致后端收到了多次请求,接到请求后首先效验表单 token 是否有效(是否存在服务端),否则判定为非法提交,在后端 PHP 众多框架中,这个步骤一般都是自动完成,比如 Yii、Laravel。

  • 还有一种稳妥的做法是,后端成功后向前端返回 302 跳转,把用户的前端页跳转到 GET 请求,把刚刚 POST 的数据给展示出来。如果是 Web 上的最好还把之前的表单设置成过期,这样用户不能通过浏览器后退按钮来重新提交。这个模式又叫做 PRG 模式 (Post/Redirect/Get)。

以上内容参考自,陈皓:《左耳听风》

Q2:如果保证 DB 与 Cache 数据一致?

场景:当用户更新自己的信息时,你的程序代码如何写:

  1. 先更新 DB 再更新 Cache,
  2. 先更新 DB 再删除 Cache
  3. 先更新 Cache 再更新 DB
  4. 先删除 Cache 再更新 DB

这里就此问题,上述 3 个步骤,再我去面试前完全没有细细思考过,事后仔细反思下,大有文章,于是小描一下。

参考大厂「Facebook」套路:缓存旁路模式(Cache Aside Pattern),大白话就是:先更新数据库,再删缓存。

对于为什么是缓存删除而不是采用缓存更新,对于实际业务中我们缓存的信息往往不单单是纯粹的表中的一行数据,有可能涉及到计算属性或者关联属性,相比删除缓存,让请求后自建缓存更容易。

Cache-Aside-Design

Updating-Data-using-the-Cache

此方案有两个潜在问题:

  1. 如果发生更新数据库成功,删除缓存失败呢?
  2. 在高并发的场景下,会不会出现数据库与缓存数据不一致的概率呢?

对于第一种方法问题的解决方案:

retry-cache-db

处理流程步骤:

  1. 更新数据库数据
  2. 缓存因为种种问题删除失败
  3. 将需要删除的key发送至消息队列
  4. 消费队列消息,获得需要删除的 key 继续重试删除操作,直到成功

参考地址:

  1. 酷壳
  2. 算法网
  3. 业余草