【案例分析】分布式系统的接口幂等性设计!
共 2929字,需浏览 6分钟
·
2021-04-10 09:36
概念
幂等性, 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 游戏坑钱了……
上面错误的示例只是扯个蛋, 咳咳…… 从这个问题中可以折射出几点系统设计的问题:
buy_commodity() 接口不符合幂等性 , 当重复操作时, 对整个系统产生了影响, 玩家 A 被多扣了点券, 在网游业务中, 一旦涉及到钱这种敏感数据, 往往就不妙了.
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 的预期, 带来了体验上的心理落差.
解决之道:
配置 shop_svr 集群前端的 负载均衡器 , 通过一定的 路由算法 保证 Jack 的请求消息路由到固定某个 shop_svr_j 上处理. 同时, 请求消息的传递通过 消息队列 (TCP 也是个朴素的实现) 来 保证顺序 , 这样, Jack 先发的请求 request 1 一定在后发的请求 request 2 之前到达, 并被处理, 从而避免时序的影响.