⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 blog.csdn.net/rickiyeat/article/details/71087747 「rickiyeat」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

概念

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

解决之道:

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