接口幂等性

接口幂等性设计

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

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

查询和删除业务

  • 查询和删除业务,天然的具有幂等的特性;
  1. 查询操作 在数据不变的情况下,查询一次和查询多次,查询结果是一样的;
  2. 删除操作 删除一次和多次删除的结果都是把数据删除;
  • 新增和更新的业务接口,如果不做幂等性处理,每调用一次,都会对系统的存储产生影响;

新增业务类接口

我们要解决如下两个问题

  1. 同一个用户用同样的数据多次请求同一个接口(不管是什么原因多次提交,他应该只请求一次)
    • 可以通过防重复提交来解决 : 业务数据连同Token,一起提交给接口,同一个Token只能被处理一次

      • 这里要注意,只能被处理一次,应该改成只能被正确的处理一次,也就是说,我们应该缓存某次新增业务处理的结果,如果上一次请求时出现某些异常,比如数据库连接失败,用户再次提交的时候,我们应该放行用户的这次请求,当然有些异常就不需要放行了,比如提交的业务数据不对等
  2. 不同用户的提交同样的数据请求同一个接口;
    • 一个开放的系统,不能杜绝两个不同的客户端(用户)同时请求;但是可以交给数据的最后防线,存储层;通过唯一索引或唯一组合索引可以防止新增数据存在脏数据 (当表存在唯一索引,并发时新增报错时,再查询一次就可以了,数据应该已经存在了,返回结果即可)

注意: Token防重复提交,只需要网关这层控制即可;Token的处理机制,还需要缓存调用的处理结果,以判断是否需要放行后续的重试请求;

系统中的大部分业务都可以归属到更新业务,比如禁用用户、电商秒杀等等,只要是有更新操作的,不管是不是还有其他的操作,都归结到更新业务;

更新业务接口

不仅需要有表单防重复提交的验证,还需要有下面这些更精细的控制,以防止高并发环境中出现脏读,幻读等引起错误的数据更新结果; 更新业务接口幂等性解决方案一般是通过各种层面的 锁和CAS机制

  • 悲观锁,select for update,整个执行过程中锁定要操作的记录;

  • 乐观锁 , 更新业务的接口,比如订单付款等,需要综合使用尽可能多的信息来逐步验证逐步减少直至杜绝重复消息重复处理的概率;基本思路是CAS(Compare And Set)

  • 状态机幂等 (TCC中就有类似的判断)
    在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机(状态变更图),就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。

    注意:订单等单据类业务,存在很长的状态流转,一定要深刻理解状态机,对业务系统设计能力提高有很大帮助

案例分析

举一个游戏领域中的案例 来自网络 :

玩家 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 的预期, 带来了体验上的心理落差.

解决之道

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

转载

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

总结

  1. 乐观锁,版本号控制
  2. 数据库防重表
  3. 分布式锁(redis,zk)
  4. token令牌
  5. source来源,seq序列号
    source+seq在数据库里面做唯一索引,防止多次付款
posted @ 2021-06-27 16:48  沉梦匠心  阅读(137)  评论(1)    收藏  举报