什么是spring?

Spring是一个轻量级Java开发框架,最早由Rod Johnson创建,目的是为了解决企业级应用开发的业务逻辑层和其他各层的耦合问题。它是一个分层的JavaSE/JavaEE一站式轻量级开源框架,为开发Java应用程序提供全面的基础架构支持。Spring负责基础架构,因此Java开发者可以专注于应用程序的开发。Spring最根本的使命是解决企业级应用开发的复杂性,即简化Java开发。
Spring可以做很多事情,它为企业级开发提供了丰富的功能,但是这些功能的底层都依赖于它的两个核心特性,也就是依赖注入(dependency injection,DI)和面向切面编程(aspect-oriented programming,AOP)。
为了降低Java开发的复杂性,Spring采取了以下4中关键策略

  • 基于POJO的轻量级和最小侵入性编程;
  • 通过依赖注入和面向接口实现松耦合;
  • 基于切面和惯例进行声明式编程;
  • 通过切面和模板减少样板式代码。

    Spring框架的设计目标,设计理念和核心是什么?

    Spring设计目标:Spring为开发者提供一个一站式轻量级应用开发平台;
    Spring设计理念:在JavaEE开发中,支持POJO和JavaBean开发方式,使应用面向接口开发,充分支持OO(面向对象)设计方法;Spring通过IOC容器实现对象耦合关系的管理,并实现依赖反转,将对象之间的依赖关系交给IOC容器,实现解耦;
    Spring框架的核心:IOC容器和AOP模块,通过IOC容器管理POJO对象以及他们之间的耦合关系;通过AOP以动态非侵入的方式增强服务。IOC让相互协作的组件保持松散的耦合,而AOP编程允许你把遍布于应用各层的功能分离出来形成可重用的功能组件。

Redis

主从复制初始化步骤:

  • slave向master发送slaveof的命令
  • mater执行bgsave的命令,生成rdb数据快照,发送给slave
  • slave加载rdb,还原数据,主从保持一致
  • 之后master执行的写操作都会发往slave执行,保持数据同步

    Redis Sentinel哨兵模式

    sentinel如何感知redis实例?

  • 每个sentinel以每秒钟一次的频次向它所感知的master、slave以及其他sentinel实例发送一个PING的命令

  • 如果一个实例距离最后一次有效回复PING命令的时间超过down-after-milliseconds选项所有指定的值,则这个实例会被sentinel标记为主观下线
  • 当有足够数量的sentinel(大于等于配置文件指定的值)在指定的时间范围内确认该实例的确进入了主观下线的状态,则该实例会被标记为客观下线

sentinel从slave中选出新的mater的条件?

  • 剔除主观下线、已断线或者最后一次回复PING命令的时间大于五秒的slave
  • 剔除与已失效主服务器连接断开的时间超过down-after-period选项指定时长十倍的slave
  • 按同步数据的偏移量(replication-offset)最完整的slave
  • 偏移量相同则选择runid较小的实例

sentinal选取master的步骤

  • 向被选中是slave发送slaveof no one的命令,让它转为主服务器
  • 通过发布与订阅的功能,将更新后的配置传播给所有其他的sentinel,其他的sentinel对它们自己的配置进行更新
  • 向所有slave下达slaveof的命令,指向新的主
  • redis-slave向master重新建立连接,重放rdb保持数据同步
  • 在上述转移过程中,伴随着Redis本地配置文件的自动重写,这样即使是实例重启配置也不会丢失
  • 原有的master在恢复后降级为slave,与新的master全量同步

sentinal leader的选举

  1. sentinel自动故障迁移使用raft算法来选举领头(leader)sentinel
  2. 超过半数投票选出leader,sentinel leader用于下达故障转移的指令
  3. 如果某个leader挂了,则使用Raft从剩余的sentinel中选举出leader

    Redis Cluster集群模式

    特点:

  4. cluster模式是redis3.0开始推出

  5. 采用无中心结构,每个节点保存数据和整个集群状态,每个节点都和其他所有节点连接
  6. 官方要求:至少6个节点才能保证高可用,即3主3从;扩展性强、更好做到高可用
  7. 各个节点会互相通信,采用gossip协议交换节点元数据信息
  8. 数据分散存储到各个节点上

Redis Cluster集群和Redis Sentinel有什么区别?
Redis Sentinel

  • 每个节点持有全量数据,且数据保持一致
  • 为系统Redis高可用

Redis Cluster:

  • 每个节点主数据不同,是数据的子集
  • 利用多台服务器构建集群提高超大规模数据处理能力
  • 同时提供高可用支持

Redis Cluster集群如何将数据分散存储?
Redis Cluster采用Hash Slot(哈希槽)分配Redis集群预分好16384个槽,初始化集群时平均规划给每一台Redis Master
crc16算法
image.png
为什么是16384个槽?

  • 心跳消息的消息头中最占空间的就是myslots的char数组,进行bitmap压缩后是16384/8/1024=2kb,(为什么是8个字节?因为Redis是c语言写的,c语言中char占1个字节),如果是65536则发送的心跳消息的达8kb,发送的心跳包过于庞大。
  • 通常redis主节点的数量不会超过1000

垂直分表

一张表的字段太多需要做垂直分表。

什么是水平分表?
以行为单位对数据进行拆分(范围法,hash法)。特点:所有的表结构完全相同。用于解决数据量大的存储问题。

什么是垂直分表?
将表按列拆分成2张以上的小表,通过主外键关联获取数据。

为什么要这么做?
需要了解mysql的InnoDB处理引擎。
行数据称为:row
管理数据基本单位称为页:page;每一页的默认大小:16k
保存页的单位称为区:Extent。
关系:区由连续页组成,页由连续行组成。1024/16=64(即:一个1M的区有64个页)

InnoDB1.0后新特性,压缩页。
压缩页:对数据底层进行压缩,使实际大小小于逻辑大小。
在跨页检索数据的过程中,压缩和解压缩的效率低。在表设计时,尽可能在页内多存储行数据,减少跨页检索,增加页内检索。

分析:
1行数据为1K,1页16K,即1页16条数据,1亿的数据需要625万页
垂直分页后,1行数据为64字节(1K=1024字节),即1页256条数据,1亿的数据需要39万页。分页后的数据根据id等关系进行快速提取。
通过将重要字段单独剥离成小表,让每页容纳更多行数据,页减少后,缩小数据扫描范围,达到提高执行效率的目的。

垂直分表条件:
1.单表数据达千万
2.字段超20个,且包含vachar,CLOB,BLOB等字段

字段放大小表的依据:
小表:数据查询、排序时需要的字段;高频访问的小字段
大表:低频访问字段;大字段

多级缓存架构图

image.png
进程外缓存:即为分布式缓存(redis)
常见的加缓存是直接加redis是不严谨
需要按照:先近到远,先快后慢逐级访问
场景:商品秒杀,若无本地缓存,都保存在redis 每完成一笔交易,局域网会进行若干网络通信,可能存在网络异常不稳定因素
且redis会承担所有节点的压力,当突发流量若超过容载上限redis会崩溃
所有java的应用端也需要设计多级缓存

一般会通过进程内缓存和进程外缓存(分布式缓存)组合分担压力
ehcache(进程内缓存)可以在缓存不存在时去redis进程外缓存进行读取,redis没有读取数据库 数据库再对ehcache,redis进行更新

缓存一致性处理:
场景:修改商品价格为80,如何保证缓存也进行更新
处理方法:引入消息队列(MQ)的主动推送功能,对服务实例推送变更实例
即:修改商品价格为80,向MQ发送变更消息,MQ将消息推送到服务实例服务实例将原缓存数据删除,再创建缓存

什么情况适用多级缓存架构
1、缓存数据稳定
2、可能产生高并发场景(12306)应用启动时进行预热处理,访问前将热点数据先缓存,减少后端压力
3、一定程度上允许数据不一致不重要的信息更新处理方式:T+1,ETL日中处理

大厂大表做分库分表为什么不建议用自增主键

自增主键在分布式环境下不适用。
由于自增主键必须连续,所以按范围法进行分片,ID的数量已固定。无法进行动态扩展。会产生“尾部热点”效应。
尾部热点:即按范围法进行分片后,前面的分片已储存数据,最后一个分片的压力很大。
Hash分片的效率更高。

使用UUID替代自增主键吗?不可以、
涉及数据库底层机制:
1.uuid,唯一无序。无序导致索引重排。主键有序的情况下,B+树只需要在原有的数据后面追加即可。

怎么解决?分布式且有序的主键生成算法?
雪花算法(SnowFlake),推特公司。
结构:符号位(1bit)+时间戳(41bit)+机器ID(10bit)+序列(12bit)
使用方法:直接调用JAR包

雪花算法需要注意时间回拨带来的影响。可能出现id重复的可能。

CAP定理

什么是CAP定理。
分布式架构的基本理论。
指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。
C:更新操作成功后,所有节点在同一时间的数据完全一致。(复习:事务的一致性:事务前后的数据完整性保持一致)
A:用户访问数据时,系统能否在正常响应时间返回预期结果。(复习:事务的原子性:事务是一个不可分割的工作单位,事务要么发生要么不发生)
P:分布式系统遇到某节点或网络分区故障的时候,仍能对外提供一致性和可用性的服务。

CAP这三个要素最多只能同时实现两点,不可能三者兼顾。
所有只有CP,AP,AC

当前场景:订单系统下单买了1瓶酒,库存系统酒的数量-1。分布式系统中,系统之间需要网络通信等各种问题。无法实现买了1瓶酒,库存即时-1。
CP:订单创建后,等待库存减少后才返回结果。保证数据一致,强一致性表现,用户体验差。(类似银行存钱)
AP:订单创建后,不等待库存减少后就返回结果。那库存数据怎么办?(异步处理后通知订单系统,若异步处理失败,有补偿机制(重新发请求,补录,校对程序)保证数据一致)。(类似淘宝)
AC:不拆分数据库系统,在一个数据库的一个事务中完成操作,即单体应用。下单,减库存在一个事务。缺点:不能做分区, 分区涉及网络,进而涉及分区容错性,进而选CP,AP。

负载均衡

负载均衡
优点:高可用、设备压力瓶酒分配,支持故障发现和转移

负载均衡的种类:

1、硬件负载均衡(F5),软件负载均衡
2、网络层面:
4层代理(指网络7层模型(OSI)的传输层,TCP),举例:Linux的LVS
7层代理(指网络7层模型(OSI)的应用层,HTTP),举例:Nginx

Nginx:内置负载均衡策略有哪些?
轮询策略(默认)、权重策略、IP_HASH、URL_HASH(第三方模块)、FAIR(第三方模块)

轮询策略:4个任务,你负责1,我负责2,你负责3,我负责4,类似。适用于性能相当的服务器。
权重策略:4个任务,你能力强,你做3个,我能力差,我做1个。适用于性能不一致的服务器。
IP_HASH:有多个用户访问Nginx,通过用户的IP对N取模(N台服务器),该IP一直由对应的服务器负责。不建议使用,无法保证服务器的均衡。
URL_HASH:类似IP_HASH,比IP_HASH更精确,但仍无法保证服务器的均衡。
FAIR:谁有空谁干活。使用心跳包判断哪台服务器闲置,然后把请求送达闲置服务器。现实使用较少。了解即可

JVM调优

image.png
OOM排查:

  • jps查询java进程有哪些
  • jstat -gcutil 进程号 1000 10
    • 查询进程占用空间
    • 1秒钟一次,生成10次
  • arthas阿里的调优工具

image.png

  • heapdump /temp/dump-1.hprof 生成dump文件
  • 使用visualVM打开dump文件
    • file -> load 选择指定的dump文件
  • 单击Open in New Tab

image.png

  • 点击GC Root

image.png
image.png

如何解决OOM问题

image.png

分布式缓存

先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。因此,接下来讨论的思路不依赖于给缓存设置过期时间这个方案。在这里,我们讨论三种更新策略:
先更新数据库,再更新缓存
先删除缓存,再更新数据库
先更新数据库,再删除缓存
应该没人问我,为什么没有先更新缓存,再更新数据库这种策略。
(1)先更新数据库,再更新缓存
这套方案,大家是普遍反对的。为什么呢?有如下两点原因。原因一(线程安全角度)同时有请求A和请求B进行更新操作,那么会出现(1)线程A更新了数据库(2)线程B更新了数据库(3)线程B更新了缓存(4)线程A更新了缓存这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。原因二(业务场景角度)有如下两点:(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
接下来讨论的就是争议最大的,先删缓存,再更新数据库。还是先更新数据库,再删缓存的问题。
(2)先删缓存,再更新数据库
该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:(1)请求A进行写操作,删除缓存(2)请求B查询发现缓存不存在(3)请求B去数据库查询得到旧值(4)请求B将旧值写入缓存(5)请求A将新值写入数据库上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。那么,如何解决呢?采用延时双删策略伪代码如下
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}
转化为中文描述就是(1)先淘汰缓存(2)再写数据库(这两步和原来一样)(3)休眠1秒,再次淘汰缓存这么做,可以将1秒内所造成的缓存脏数据,再次删除。那么,这个1秒怎么确定的,具体该休眠多久呢?针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。如果你用了mysql的读写分离架构怎么办?ok,在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。(1)请求A进行写操作,删除缓存(2)请求A将数据写入数据库了,(3)请求B查询缓存发现,缓存没有值(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值(5)请求B将旧值写入缓存(6)数据库完成主从同步,从库变为新值上述情形,就是数据不一致的原因。还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。采用这种同步淘汰策略,吞吐量降低怎么办?ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。第二次删除,如果删除失败怎么办?这是个非常好的问题,因为第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库:(1)请求A进行写操作,删除缓存(2)请求B查询发现缓存不存在(3)请求B去数据库查询得到旧值(4)请求B将旧值写入缓存(5)请求A将新值写入数据库(6)请求A试图去删除请求B写入对缓存值,结果失败了。ok,这也就是说。如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。如何解决呢?具体解决方案,且看博主对第(3)种更新策略的解析。
(3)先更新数据库,再删缓存
首先,先说一下。老外提出了一个缓存更新套路,名为《Cache-Aside pattern》。其中就指出
失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从cache中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。
另外,知名社交网站facebook也在论文《Scaling Memcache at Facebook》中提出,他们用的也是先更新数据库,再删缓存的策略。
这种情况不存在并发问题么?
不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生
(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存
ok,如果发生上述情况,确实是会发生脏数据。
然而,发生这种情况的概率又有多少呢?
发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。假设,有人非要抬杠,有强迫症,一定要解决怎么办?
如何解决上述并发问题?首先,给缓存设有效时间是一种方案。其次,采用策略(2)里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。
还有其他造成不一致的原因么?有的,这也是缓存更新策略(2)和缓存更新策略(3)都存在的一个问题,如果删缓存失败了怎么办,那不是会有不一致的情况出现么。比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况了。这也是缓存更新策略(2)里留下的最后一个疑问。
如何解决?提供一个保障的重试机制即可,这里给出两套方案。方案一:如下图所示
Java常见问题 - 图9
流程如下所示(1)更新数据库数据;(2)缓存因为种种问题删除失败(3)将需要删除的key发送至消息队列(4)自己消费消息,获得需要删除的key(5)继续重试删除操作,直到成功然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
方案二:
Java常见问题 - 图10
流程如下图所示:(1)更新数据库数据(2)数据库会将操作信息写入binlog日志当中(3)订阅程序提取出所需要的数据以及key(4)另起一段非业务代码,获得该信息(5)尝试删除缓存操作,发现删除失败(6)将这些信息发送至消息队列(7)重新从消息队列中获得该数据,重试操作。
备注说明:上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。至于oracle中,博主目前不知道有没有现成中间件可以使用。另外,重试机制,博主是采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。

分布式事务

XA分布式事务协议,包含二阶段提交(2PC),三阶段提交(3PC)两种实现。
1、二阶段提交方案:强一致性
事务的发起者称协调者,事务的执行者称参与者。
处理流程:
1、准备阶段
事务协调者,向所有事务参与者发送事务内容,询问是否可以提交事务,并等待参与者回复。
事务参与者收到事务内容,开始执行事务操作,讲 undo 和 redo 信息记入事务日志中(但此时并不提交事务)。
如果参与者执行成功,给协调者回复yes,表示可以进行事务提交。如果执行失败,给协调者回复no,表示不可提交。
2、提交阶段
如果协调者收到了参与者的失败信息或超时信息,直接给所有参与者发送回滚(rollback)信息进行事务回滚,否则,发送提交(commit)信息。
参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源) 接下来分两种情况分别讨论提交阶段的过程。
Java常见问题 - 图11

简单一点理解,可以把协调者节点比喻为带头大哥,参与者理解比喻为跟班小弟,带头大哥统一协调跟班小弟的任务执行。
阶段 1:准备阶段
准备阶段有如下三个步骤:

  • 协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待所有参与者答复。
  • 各参与者执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
  • 如参与者执行成功,给协调者反馈 yes,即可以提交;如执行失败,给协调者反馈 no,即不可提交。

阶段 2:提交阶段
如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(rollback)消息;否则,发送提交(commit)消息。
参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源) 接下来分两种情况分别讨论提交阶段的过程。
情况 1,当所有参与者均反馈 yes,提交事务,如上图:

  • 协调者向所有参与者发出正式提交事务的请求(即 commit 请求)。
  • 参与者执行 commit 请求,并释放整个事务期间占用的资源。
  • 各参与者向协调者反馈 ack(应答)完成的消息。
  • 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。

image.png
情况 2,当任何阶段 1 一个参与者反馈 no,中断事务,如上图:

  • 协调者向所有参与者发出回滚请求(即 rollback 请求)。
  • 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
  • 各参与者向协调者反馈 ack 完成的消息。
  • 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。

方案总结
2PC 方案实现起来简单,实际项目中使用比较少,主要因为以下问题:

  • 性能问题:所有参与者在事务提交阶段处于同步阻塞状态,占用系统资源,容易导致性能瓶颈。
  • 可靠性问题:如果协调者存在单点故障问题,如果协调者出现故障,参与者将一直处于锁定状态。
  • 数据一致性问题:在阶段 2 中,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就导致了节点之间数据的不一致。

2、3阶段提交
三阶段提交是在二阶段提交上的改进版本,主要是加入了超时机制。同时在协调者和参与者中都引入超时机制。
三阶段将二阶段的准备阶段拆分为2个阶段,插入了一个preCommit阶段,以此来处理原先二阶段,参与者准备后,参与者发生崩溃或错误,导致参与者无法知晓是否提交或回滚的不确定状态所引起的延时问题。
处理流程
阶段 1:canCommit
协调者向参与者发送 commit 请求,参与者如果可以提交就返回 yes 响应(参与者不执行事务操作),否则返回 no 响应:

  • 协调者向所有参与者发出包含事务内容的 canCommit 请求,询问是否可以提交事务,并等待所有参与者答复。
  • 参与者收到 canCommit 请求后,如果认为可以执行事务操作,则反馈 yes 并进入预备状态,否则反馈 no。

阶段 2:preCommit
协调者根据阶段 1 canCommit 参与者的反应情况来决定是否可以进行基于事务的 preCommit 操作。根据响应情况,有以下两种可能。
image.png
情况 1:阶段 1 所有参与者均反馈 yes,参与者预执行事务,如上图:

  • 协调者向所有参与者发出 preCommit 请求,进入准备阶段。
  • 参与者收到 preCommit 请求后,执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
  • 各参与者向协调者反馈 ack 响应或 no 响应,并等待最终指令。

image.png
情况 2:阶段 1 任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务,如上图:

  • 协调者向所有参与者发出 abort 请求。
  • 无论收到协调者发出的 abort 请求,或者在等待协调者请求过程中出现超时,参与者均会中断事务。

阶段 3:do Commit
该阶段进行真正的事务提交,也可以分为以下两种情况。
image.png
情况 1:阶段 2 所有参与者均反馈 ack 响应,执行真正的事务提交,如上图:

  • 如果协调者处于工作状态,则向所有参与者发出 do Commit 请求。
  • 参与者收到 do Commit 请求后,会正式执行事务提交,并释放整个事务期间占用的资源。
  • 各参与者向协调者反馈 ack 完成的消息。
  • 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。

image.png
情况 2:阶段 2 任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务,如上图:

  • 如果协调者处于工作状态,向所有参与者发出 abort 请求。
  • 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
  • 各参与者向协调者反馈 ack 完成的消息。
  • 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。

注意:进入阶段 3 后,无论协调者出现问题,或者协调者与参与者网络出现问题,都会导致参与者无法接收到协调者发出的 do Commit 请求或 abort 请求。此时,参与者都会在等待超时之后,继续执行事务提交。
方案总结
优点:相比二阶段提交,三阶段提交降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,阶段 3 中协调者出现问题时,参与者会继续提交事务。
缺点:数据不一致问题依然存在,当在参与者收到 preCommit 请求后等待 do commite 指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。
TCC 事务:最终一致性
方案简介
TCC(Try-Confirm-Cancel)的概念,最早是由 Pat Helland 于 2007 年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。
TCC 是服务化的二阶段编程模型,其 Try、Confirm、Cancel 3 个方法均由业务编码实现:

  • Try 操作作为一阶段,负责资源的检查和预留。
  • Confirm 操作作为二阶段提交操作,执行真正的业务。
  • Cancel 是预留资源的取消。

TCC 事务的 Try、Confirm、Cancel 可以理解为 SQL 事务中的 Lock、Commit、Rollback。
处理流程
为了方便理解,下面以电商下单为例进行方案解析,这里把整个过程简单分为扣减库存,订单创建 2 个步骤,库存服务和订单服务分别在不同的服务器节点上。
①Try 阶段
从执行阶段来看,与传统事务机制中业务逻辑相同。但从业务角度来看,却不一样。
TCC 机制中的 Try 仅是一个初步操作,它和后续的确认一起才能真正构成一个完整的业务逻辑,这个阶段主要完成:

  • 完成所有业务检查( 一致性 ) 。
  • 预留必须业务资源( 准隔离性 ) 。
  • Try 尝试执行业务。

TCC 事务机制以初步操作(Try)为中心的,确认操作(Confirm)和取消操作(Cancel)都是围绕初步操作(Try)而展开。
因此,Try 阶段中的操作,其保障性是最好的,即使失败,仍然有取消操作(Cancel)可以将其执行结果撤销。
image.png
假设商品库存为 100,购买数量为 2,这里检查和更新库存的同时,冻结用户购买数量的库存,同时创建订单,订单状态为待确认。
②Confirm / Cancel 阶段
根据 Try 阶段服务是否全部正常执行,继续执行确认操作(Confirm)或取消操作(Cancel)。
Confirm 和 Cancel 操作满足幂等性,如果 Confirm 或 Cancel 操作执行失败,将会不断重试直到执行完成。
Confirm:当 Try 阶段服务全部正常执行, 执行确认业务逻辑操作
image.png
这里使用的资源一定是 Try 阶段预留的业务资源。在 TCC 事务机制中认为,如果在 Try 阶段能正常的预留资源,那 Confirm 一定能完整正确的提交。
Confirm 阶段也可以看成是对 Try 阶段的一个补充,Try+Confirm 一起组成了一个完整的业务逻辑。
Cancel:当 Try 阶段存在服务执行失败, 进入 Cancel 阶段
image.png
Cancel 取消执行,释放 Try 阶段预留的业务资源,上面的例子中,Cancel 操作会把冻结的库存释放,并更新订单状态为取消。
方案总结
TCC 事务机制相对于传统事务机制(X/Open XA),TCC 事务机制相比于上面介绍的 XA 事务机制,有以下优点:

  • 性能提升:具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
  • 数据最终一致性:基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
  • 可靠性:解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。

缺点: TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。

GC优化案列

案例一 Major GC和Minor GC频繁

确定目标

服务情况:Minor GC每分钟100次 ,Major GC每4分钟一次,单次Minor GC耗时25ms,单次Major GC耗时200ms,接口响应时间50ms。
由于这个服务要求低延时高可用,结合上文中提到的GC对服务响应时间的影响,计算可知由于Minor GC的发生,12.5%的请求响应时间会增加,其中8.3%的请求响应时间会增加25ms,可见当前GC情况对响应时间影响较大。
(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3%
优化目标:降低TP99、TP90时间。

优化

首先优化Minor GC频繁问题。通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。
这时很多人有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?根据上面公式,如果单次Minor GC时间也增加,很难保证最后的优化效果。我们结合下面情况来分析,单次Minor GC时间主要受哪些因素影响?是否和新生代大小存在线性关系? 首先,单次Minor GC时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到Survivor区)如下图。(注:这里为了简化问题,我们认为T1只扫描新生代判断对象是否存活的时间,其实该阶段还需要扫描部分老年代,后面案例中有详细描述。)
image.png

  • 扩容前:新生代容量为R ,假设对象A的存活时间为750ms,Minor GC间隔500ms,那么本次Minor GC时间= T1(扫描新生代R)+T2(复制对象A到S)。
  • 扩容后:新生代容量为2R ,对象A的生命周期为750ms,那么Minor GC间隔增加为1000ms,此时Minor GC对象A已不再存活,不需要把它复制到Survivor区,那么本次GC时间 = 2 × T1(扫描新生代R),没有T2复制时间。

可见,扩容后,Minor GC时增加了T1(扫描时间),但省去T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。下面需要确认下服务中对象的生命周期分布情况:
image.png
通过上图GC日志中两处红色框标记内容可知: 1. new threshold = 2(动态年龄判断,对象的晋升年龄阈值为2),对象仅经历2次Minor GC后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的Major GC。 2. Major GC后老年代使用空间为300M+,意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活,也就是说生命周期长的对象占比很小。
由此可见,服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。

优化结果

通过扩容新生代为为原来的三倍,单次Minor GC时间增加小于5ms,频率下降了60%,服务响应时间TP90,TP99都下降了10ms+,服务可用性得到提升。
调整前:image.png
调整后:image.png

小结

如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

更多思考

关于上文中提到晋升年龄阈值为2,很多同学有疑问,为什么设置了MaxTenuringThreshold=15,对象仍然仅经历2次Minor GC,就晋升到老年代?这里涉及到“动态年龄计算”的概念。
动态年龄计算:Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值。在本案例中,调优前:Survivor区 = 64M,desired survivor = 32M,此时Survivor区中age<=2的对象累计大小为41M,41M大于32M,所以晋升年龄阈值被设置为2,下次Minor GC时将年龄超过2的对象被晋升到老年代。
JVM引入动态年龄计算,主要基于如下两点考虑:

  1. 如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件: a)MaxTenuringThreshold设置的过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Svuvivor中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。 b)MaxTenuringThreshold设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的Major GC。分代回收失去了意义,严重影响GC性能。
  2. 相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。

总结来说,为了更好的适应不同程序的内存情况,虚拟机并不总是要求对象年龄必须达到Maxtenuringthreshhold再晋级老年代。

案例二 请求高峰期发生GC,导致服务可用性下降

确定目标

GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。
image.png

优化

解决问题前,先回顾一下CMS的四个主要阶段,以及各个阶段的工作内容。下图展示了CMS各个阶段可以标记的对象,用不同颜色区分。 1. Init-mark初始标记(STW) ,该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。 2. Concurrent-mark并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。 3. Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。 4. 并发清理,进行并发的垃圾清理。
image.png
可见,Remark阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?CMS对老年代做回收,Remark阶段仅扫描老年代是否可行?结论是不可行,原因如下:image.png
如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象A因为引用存在新生代中,它在Remark阶段就不会被修正标记为可达,GC时会被错误回收。 新生代对象持有老年代中对象的引用,这种情况称为“跨代引用”。因它的存在,Remark阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。
灰色对象已经不可达,但仍然需要扫描的原因:新生代GC和老年代的GC是各自分开独立进行的,只有Minor GC时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在Minor GC发生前不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。 分析GC日志可以得出同样的规律,Remark耗时>500ms时,新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量。
新生代中对象的特点是“朝生夕灭”,这样如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过2M时启动,当然2M是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark阶段需要扫描的对象就少了。
除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark。 根据GC日志红色标记2处显示,可中断的并发预清理执行了5.35s,超过了设置的5s被中断,期间没有等到Minor GC ,所以Remark时新生代中仍然有很多对象。
对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。

优化结果

经过增加CMSScavengeBeforeRemark参数,单次执行时间>200ms的GC停顿消失,从监控上观察,GCtime和业务波动保持一致,不再有明显的毛刺。image.png

小结

通过案例分析了解到,由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。

更多思考

案例中只涉及老年代GC,其实新生代GC存在同样的问题,即老年代可能持有新生代对象引用,所以Minor GC时也必须扫描老年代。
JVM是如何避免Minor GC时扫描全堆的? 经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。如下图所示:
image.png
卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。
总结来说,CMS的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。