微服务架构及幂等性
微服务架构
微服务架构是一种架构概念,旨在通过将功能分解到各个离散的服务中以实现对解决方案的解耦。它的主要作用是将功能分解到离散的各个服务当中,从而降低系统的耦合性,并提供更加灵活的服务支持。
和 微服务 相对应的,这种方式一般被称为 单体式开发(Monolithic)。既所有的功能打包在一个 WAR 包里,基本没有外部依赖(除了容器),部署在一个 JavaEE 容器(Tomcat,JBoss,WebLogic)里,包含了 DO/DAO,Service,UI 等所有逻辑。
单体应用的优缺点:
优点:
- 开发简单,集中式管理
- 基本不会重复开发
- 功能都在本地,没有分布式的管理和调用消耗
缺点:
- 效率低:开发都在同一个项目改代码,相互等待,冲突不断
- 维护难:代码功能耦合在一起,新人不知道何从下手
- 不灵活:构建时间长,任何小修改都要重构整个项目,耗时
- 稳定性差:一个微小的问题,都可能导致整个应用挂掉
- 扩展性不够:无法满足高并发下的业务需
微服务的优缺点:
优点:
- 它解决了复杂问题。它把可能会变得庞大的单体应用程序分解成一套服务
- 这种架构使得每个服务都可以由一个团队独立专注开发
- 微服务架构模式可以实现每个微服务独立部署
- 微服务架构模式使得每个服务能够独立扩展
缺点(挑战):
- 微服务是一个分布式系统,其使得整体变得复杂。开发人员需要基于RPC或者消息实现微服务之间的调用和通信,而这就使得服务之间的发现、服务调用链的跟踪和质量问题变得的相当棘手。
- 分区的数据库体系和分布式事务。更新多个业务实体的业务交易相当普遍。这些类型的事务在单体应用中实现非常简单,因为单体应用往往只存在一个数据库。但在微服务架构下,不同服务可能拥有不同的数据库。CAP原理的约束,使得我们不得不放弃传统的强一致性,而转而追求最终一致性,这个对开发人员来说是一个挑战。
- 跨越多服务变更。在单体应用程序中,您可以简单地修改相应的模块、整合变更并一次性部署他们。相反,在微服务中您需要仔细规划和协调出现的变更至每个服务。
- 部署基于微服务的应用程序也是相当复杂的
- 测试
以上问题和挑战可大体概括为:
- API Gateway
- 服务间调用
- 服务发现
- 服务容错
- 服务部署
- 数据调用
目前流行的两种微服务框架解决方案(可以解决以上问题)
- SpringBoot + SpringCloud
- SpringBoot是 Spring 的一套快速配置框架,可以基于spring boot 快速开发单个微服务
- Spring Cloud基于Spring Boot,为微服务体系开发中的架构问题提供了一整套的解决方案——服务注册与发现,服务消费,服务保护与熔断,网关,分布式调用追踪,分布式配置管理等路由网关
- Dubbo + ZooKeeper
- Dubbo是一个阿里巴巴开源出来的一个分布式服务框架,致力于提供高性能和透明化的RPC远程服务调用方案,以及SOA服务治理方案。
- ZooKeeper用来实现服务的注册与发现和进行负载均衡
基于微服务架构的应用是分布式系统,增加了系统设计和实现的难度,主要有以下方面:
- 运行环境中,微服务实例的网络地址是动态分配的,微服务运行的实例是动态变化的,需要一套发现机制,使服务调用者可以获取正确的服务地址。
- 服务之间存在着复杂的依赖关系,在高并发访问下,依赖的稳定性影响系统的可用性及性能,因此需要提供容错机制,当依赖的服务发生故障时,不会使调用方线程被长时间占用不释放,避免故障在系统中蔓延。
- 需要一个高效的进程间通信机制支持微服务之间的交互。
- 服务实例的启停及流量的调度和分配需要一套负载监控和负载均衡组件来管理。
微服务架构的基本组件
- 可持续交付的平台
- 服务注册与发现组件(ZooKeeper)
- 服务网关
一般微服务在系统内部,通常是无状态的,用户登入信息和权限管理最好有一个统一的地方维护管理(OAuth)。(类似SSO单点登入)
API网关 + 服务间通信
- 一般在后台 N 个服务和 UI 之间一般会一个代理或者叫
API Gateway
- 提供统一服务入口,让微服务对前台透明
- 聚合后台的服务,节省流量,提升性能(PC、Android/IOS端只需记住API网关的IP,API网关会有一个后台服务IP列表)
- 提供安全,过滤,流控等API管理功能
- 其实这个
API Gateway
可以有很多广义的实现办法,可以是一个软硬一体的盒子,也可以是一个简单的 MVC 框架,甚至是一个Node.js
的服务端
所有的微服务都是独立的 Java 进程跑在独立的虚拟机上,所以服务间的通信就是 IPC(Inter Process Communication),已经有很多成熟的方案。现在基本最通用的有两种方式
服务间通信
同步调用(阻塞)
服务间通信:网络中只有字符串可以穿透防火墙
- REST(JAX-RS,Spring Boot):Http通信
REST
api
String json = /users/list;
USer user = new USer();
user.setId(json.getId());
- RPC(Thrift, Dubbo):远程过程调用
User user = new RPCUser();
同步调用比较简单,一致性强,但是容易出调用问题,性能体验上也会差些,特别是调用层次多的时候。一般 REST 基于 HTTP,更容易实现,更容易被接受,服务端实现技术也更灵活些,各个语言都能支持,同时能跨客户端,对客户端没有特殊的要求,只要封装了 HTTP 的 SDK 就能调用,所以相对使用的广一些。RPC 也有自己的优点,传输协议更高效,安全更可控,特别在一个公司内部,如果有统一个的开发规范和统一的服务框架时,他的开发效率优势更明显些。就看各自的技术积累实际条件,自己的选择了。
(对外REST,对内RPC)
异步消息调用
- Kafka
- Notify
- MessageQueue
异步消息的方式在分布式系统中有特别广泛的应用,他既能减低调用服务之间的耦合,又能成为调用之间的缓冲,确保消息积压不会冲垮被调用方,同时能保证调用方的服务体验,继续干自己该干的活,不至于被后台性能拖慢。不过需要付出的代价是一致性的减弱,需要接受数据 最终一致性;还有就是后台服务一般要实现 幂等性,因为消息送出于性能的考虑一般会有重复(保证消息的被收到且仅收到一次对性能是很大的考验);最后就是必须引入一个独立的 Broker
(消息队列的中间服务器)
服务发现
在微服务架构中,一般每一个服务都是有多个拷贝,来做负载均衡。一个服务随时可能下线,也可能应对临时访问压力增加新的服务节点。服务之间如何相互感知?服务如何管理?
这就是服务发现的问题了。一般有两类做法,也各有优缺点。基本都是通过 Zookeeper 等类似技术做服务注册信息的分布式管理。当服务上线时,服务提供者将自己的服务信息注册到 ZK(或类似框架),并通过心跳维持长链接,实时更新链接信息。服务调用者通过 ZK 寻址,根据可定制算法,找到一个服务,还可以将服务信息缓存在本地以提高性能。当服务下线时,ZK 会发通知给服务客户端。
ZooKeeper实现了分布式锁,解决单点故障问题
服务注册
Dubbo是一个RPC通信框架;ZooKeeper服务注册与发现。此处两者结合使用。
- 基于客户端的服务注册与发现
优点是架构简单,扩展灵活,只对服务注册器依赖。缺点是客户端要维护所有调用服务的地址,有技术难度,一般大公司都有成熟的内部框架支持,比如 Dubbo。
此处调用可以是Dubbo,服务注册为ZooKeeper
- 基于服务端的服务注册与发现
优点是简单,所有服务对于前台调用方透明,一般在小公司在云服务上部署的应用采用的比较多。
LB为负载均衡服务器,由LB去查询注册中心,再去调用对应指定的服务
微服务需要考虑的问题:
- API Gateway
- 服务间调用
- 服务发现
- 服务容错
- 服务部署
- 数据调用
微服务幂等性
分布式系统中的幂等性概念:用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
幂等场景:
可能会发生重复请求或消费的场景,在微服务架构中是随处可见的。
-
网络波动:因网络波动,可能会引起重复请求
-
分布式消息消费:任务发布后,使用分布式消息服务来进行消费
-
用户重复操作:用户在使用产品时,可能无意地触发多笔交易,甚至没有响应而有意触发多笔交易
-
未关闭的重试机制:因开发人员、测试人员或运维人员没有检查出来,而开启的重试机制(如Nginx重试、RPC通信重试或业务层重试等)
CRUD操作分析
-
新增类请求:不具备幂等性
-
查询类动作:重复查询不会产生或变更新的数据,查询具有天然幂等性
-
更新类请求:
- 基于主键的计算式Update,不具备幂等性,即
UPDATE goods SET number=number-1 WHERE id=1
- 基于主键的非计算式Update:具备幂等性,即
UPDATE goods SET number=newNumber WHERE id=1
- 基于条件查询的更新,不一定具备幂等性(需要根据实际情况进行分析判断)
- 基于主键的计算式Update,不具备幂等性,即
-
删除类请求:
- 基于主键的Delete具备幂等性
- 一般业务层面都是逻辑删除(即update操作),而基于主键的逻辑删除操作也是具有幂等性的
幂等性的重要性
针对一个微服务架构,如果不支持幂等操作,那将会出现以下情况:
- 电商超卖现象
- 重复转账、扣款或付款
- 重复增加金币、积分或优惠券
超卖现象
比如某商品的库存为1,此时用户1和用户2并发购买该商品,用户1提交订单后该商品的库存被修改为0,而此时用户2并不知道的情况下提交订单,该商品的库存再次被修改为-1这就是超卖现象。
究其深层原因,是因为数据库底层的写操作和读操作可以同时进行,虽然写操作默认带有隐式锁(即对同一数据不能同时进行写操作)但是读操作默认是不带锁的,所以当用户1去修改库存的时候,用户2依然可以都到库存为1,所以出现了超卖现象。
解决方案A:可以对读操作加上显式锁(即在select …语句最后加上for update)这样一来用户1在进行读操作时用户2就需要排队等待了。但问题来了,如果该商品很热门并发量很高那么效率就会大大的下降,如何解决呢?(解决方案B)
解决方案B:我们可以有条件有选择的在读操作上加锁,比如可以对库存做一个判断,当库存小于一个量时开始加锁,让购买者排队,这样一来就解决了超卖现象。
解决方案
- 全局唯一ID
如果使用全局唯一ID,就是根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。如果不存在则把全局ID,存储到存储系统中,比如数据库、Redis等。如果存在则表示该方法已经执行。
使用全局唯一ID是一个通用方案,可以支持插入、更新、删除业务操作。但是这个方案看起来很美但是实现起来比较麻烦,下面的方案适用于特定的场景,但是实现起来比较简单。
- 去重表
这种方法适用于在业务中有唯一标识的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据写入去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。
- 插入或更新
这种方法插入并且有唯一索引的情况,比如我们要关联商品品类,其中商品的ID和品类的ID可以构成唯一索引,并且在数据表中也增加了唯一索引。这时就可以使用InsertOrUpdate操作。
- 多版本控制
这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等:boolean updateGoodsName(int id,String newName,int version);
在实现时可以如下:update goods set name=#{newName},version=#{version} where id=#{id} and version<${version}
- 状态机控制
这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为100,付款失败为99。在做状态机更新时,我们就这可以这样控制:update goods_order set status=#{status} where id=#{id} and status<#{status}
以上就是保证接口幂等性的一些方法。
总结
幂等性设计不能脱离业务来讨论,一般情况下,去重表同时也是业务数据表,而针对分布式的去重ID,可以参考以下几种方式:
- UUID
- Snowflake
- 数据库自增ID
- 业务本身的唯一约束
- 业务字段+时间戳拼接