分布式系统的接口幂等性设计

概念

幂等性, Idempotence, 这个词来源自数学领域, 百科上一元运算的幂等性解释如下:

设 f 为一由 {x} 映射至 {x} 的一元运算, 则 f 为幂等的, 当对于所有在 {x} 内的 x: f(f(x)) = f(x) 特别的是,恒等函数一定是幂等的,且任一常数函数也都是幂等的。

幂等性衍生到软件工程中, 它的语义是指: 函数/接口可以使用相同的参数重复执行, 不应该影响系统状态, 也不会对系统造成改变.

一个简答的例子: 查询接口 GetFoo(), 不管调用多少次, 都不会破坏当前的系统/内存, 这就是一个幂等操作. 当然, 系统内部产生的日志这些细节不要在意.

在 HTTP/1.1 规范中, 幂等性有类似的明确定义:

Methods can also have the property of "idempotence" in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request.

从语义上不难看出, HTTP GET 是一个清晰的幂等操作, HTTP DELETE/POST 是非幂等的, HTTP PUT 也是幂等的, 因为对同一个 URI 进行多次 PUT 的 side-effetcs 是一致的.

分布式系统中, 由于分布式天然特性的时序问题, 以及网络的不可靠性(机器、机架、机房故障, 电缆被挖断等等), 重复请求很常见, 接口幂等性设计就显得尤为重要.

案例分析

举一个游戏领域中的案例:

  • 玩家 Jack 花费点券购买道具, 调用后端 shop_svr 集群的 rpc 接口 buy_commodity(commodity_id).
  • 由于网络延迟, 或者系统负载比较高, shop_svr 没来得及返回, 总之, 第一次调用超时了没返回.
  • Jack 见一直木有反映, 又点了一次购买按钮.
  • 网络恢复了, shop_svr 连续收到两次 buy_commodity(commodity_id) 请求.
  • 好吧, Jack 本来只想花 100 点券买个小喇叭, 系统硬是让他买了俩, 难怪都说 XX 游戏坑钱了……

上面错误的示例只是扯个蛋, 咳咳…… 从这个问题中可以折射出几点系统设计的问题:

  1. buy_commodity() 接口不符合幂等性, 当重复操作时, 对整个系统产生了影响, 玩家 A 被多扣了点券, 在网游业务中, 一旦涉及到钱这种敏感数据, 往往就不妙了.

  2. shop_svr 的消息处理做的不够完善, 当它收到延迟了许久的消息时, 应该及早拒绝, 返回失败, 不仅是为了避免重复调用, 更重要的是保证 shop_svr 不会过载而导致整个系统雪崩 (不过这又是另外个话题, 不在此赘述).

那么, 怎么完善 buy_commodity() 接口的幂等性呢?借鉴银行等金融系统的做法, 引入票据 (token) 是个不错的主意:

  • Jack 花费点券购买道具, 先到 shop_svr 中去申请交易票据 token.
  • shop_svr 生成唯一 token, 并记录到 DB.
  • Jack 拿到 token, 调用接口 buy_commodity(token, commodity_id) 购买.
  • 由于网络延迟, 或者系统负载比较高, shop_svr 没来得及返回, 总之, 第一次调用超时了没返回.
  • Jack 重试购买, 仍然调用接口 buy_commodity(token, commodity_id).
  • shop_svr 收到第一次 buy_commodity() 请求, 验证 token 之后完成购买行为, 再将 token 标记为已执行, 这是个原子行为.
  • shop_svr 收到第二次 buy_commodity() 请求, 验证 token 失败, 丢弃消息.

票据 (token) 机制, 保证了 buy_commodity() 接口的幂等性, 同样的请求, 并不会对系统造成额外的 side-effects, 即多次调用预期保持一致, 问题解决!

PS: 按照上面的描述, DB 层保证 "验证 token", "加道具扣点券", "标记 token" 这三步操作的原子性, 这并不是一个很容易的事情, 所以实际中往往妥协为: 先 "验证并标记 token", 再 "加道具扣点券" 这两步操作:

  • 第一步操作可以通过 SQL 的条件更新, 或者带版本号写(部分 NoSQL 支持)来实现, 这是幂等性操作.
  • 如果第一步成功, 第二步失败, 可以直接认为操作失败, 但并不会破坏接口的幂等性.

大部分的网游服务器, 是极其注重数据强一致性的, 但能容忍一定的可用性缺失. 例如: 玩家能接受每周的例行停服维护时间, 能接受某次点击服务器返回失败, 但是很难接受数据被篡改乃至回档, 这也是上面 DB 操作可以妥协的根本原因.

扩展

But, 问题真的完美解决了么?

再扩展一下上面的例子, 现在游戏火了, 为了响应迅速增大的并发请求, 游戏服务都做了扩展, 无状态的 shop_svr 也平行扩展为一个集群, 玩家的每次 buy_commodity() 请求都被负载均衡器路由到不同的 shop_svr 处理, 以平摊系统负载, 一切都看上去很好.

Jack 吃了一个礼拜泡面终于攒了 20000 点券, 准备买个"赵云-子龙"的皮肤, Jack 满心期待的点下了"购买"按钮, 额, 居然又没反应... 点了几下都如此, 纳闷儿的 Jack 顺手点了下隔壁的"闭月之颜-貂蝉"皮肤, 弹窗提示:"购买成功", 这…… Jack 哭了.

我们来回顾一下, 应该是如此的流程: 托分布式系统的福, 第二个请求 buy_commodity(token_2, "闭月之颜-貂蝉") 后发而先至, 被优先处理, 当第一个请求 buy_commodity(pay_token_1, "赵云-子龙") 在之后到达时, Jack 的点券已经被扣完了,扣完了……

这个问题跟幂等性本身无关, 从系统的行为来看, 也是符合强一致性的, 只是在时序上没能符合 Jack 的预期, 带来了体验上的心理落差.

解决之道:

  1. 配置 shop_svr 集群前端的负载均衡器, 通过一定的路由算法保证 Jack 的请求消息路由到固定某个 shop_svr_j 上处理.
  2. 同时, 请求消息的传递通过消息队列 (TCP 也是个朴素的实现) 来保证顺序, 这样, Jack 先发的请求 request 1 一定在后发的请求 request 2 之前到达, 并被处理, 从而避免时序的影响.

参考文章

  1. 《幂等性 个人理解及应用》
  2. 《理解HTTP幂等性》
  3. 《分布式高并发系统如何保证对外接口的幂等性?》