海量数据寻找中位数
消息投递:如何保证消息仅仅被消费一次?
- 生产者写入过程
消息重传
- 消息队列存储场景
当配置Kafka集群的时候,Followe中有一个ISR集合,当leader发生故障的时候,新选举出来的leader会从ISR中选出来,配置参数ack为-1的时候,生产者没发送一条信息除了发送给leader还会保证所有的ISR也会收到。
- 消息被消费者过程
在一条消息在处理之后,消费者恰好宕机了,那会因为没有更新消费进度,所以当消费者重启之后,还会重复的消费这条消息。
- 开启幂等性
- 消息生产的幂等,
- 消费端的幂等性会复杂些,可以从通用层和业务层进行考虑。
在通用层面,可以在消息生产的时候,使用信号发生器给它生成一个全局唯一的ID,消息被处理之后,ID存储在数据库,在处理下一条消息之前,先从数据库查询这个ID是否被消费,如果被消费过就不再消费。不过这样会有一个问题:要保证消息的处理和写入数据库具有事务性。
在业务层面,可以采用乐观锁的方式,具体操作如下:给每个人账户数据加一个版本号字段,在生产消息时先查询这个账户版本号,并且将这个版本号连同消息一起发送给消息队列。消费端在拿到消息和版本号后,在执行更新账户余额SQL的时候带上版本号。
系统架构设计
希望你能在后续的分布式系统的开发中,不仅掌握流量削峰、延迟响应、体验降级、过载保护 这 4 板斧,更能理解这 4 板斧背后的妥协折中,从而灵活地处理不可预知的突发问 题。
分库分表
- 数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。
- 数据文件会变得很大,数据库备份和恢复需要耗费很长时间。
- 数据文件越大,极端情况下丢失数据的风险越高(例如,机房火灾导致数据库主备机都发生故障)。
读热点加缓存,写热点加缓冲,例如用消息队列,写热点也可以合并写。
业务分库
业务分库指的是按照业务模块将数据分散到不同的数据库服务器。 例如,一个简单的电商网站,包括用户、商品、订单三个业务模块,我们可以将用户数据、商品数据、订单数据分开放到三台不同的数据库服务器上,而不是将所有数据都放在一台数据库服务器上。
虽然业务分库能够分散存储和访问压力,但同时也带来了新的问题。
- join操作
业务分库后,原本在同一个数据库中的表分散到不同数据库中,导致无法使用 SQL 的 join 查询。
- 事务问题
- 成本问题
分表
单表数据拆分有两种方式:垂直分表和水平分表。
-
垂直分表
垂直分表适合将表中某些不常用且占了大量空间的列拆分出去。 例如,前面示意图中的 nickname 和 description 字段,假设我们是一个婚恋网站,用户在筛选其他用户的时候,主要是用 age 和 sex 两个字段进行查询,而 nickname 和 description 两个字段主要用于展示,一般不会在业务查询中用到。description 本身又比较长,因此我们可以将这两个字段独立到另外一张表中,这样在查询 age 和 sex 时,就能带来一定的性能提升。 -
水品分表
水平分表相比垂直分表,会引入更多的复杂性,主要表现在下面几个方面:
路由水平分表后,某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算,这个算法会引入一定的复杂性。常见的路由算法有:
范围路由: 选取有序的数据列(例如,整形、时间戳等)作为路由的条件,不同分段分散到不同的数据库表中。以最常见的用户 ID 为例,路由算法可以按照 1000000 的范围大小进行分段,1 ~ 999999 放到数据库 1 的表中,1000000 ~ 1999999 放到数据库 2 的表中,以此类推。范围路由的优点是可以随着数据的增加平滑地扩充新的表。例如,现在的用户是 100 万,如果增加到 1000 万,只需要增加新的表就可以了,原有的数据不需要动。范围路由的一个比较隐含的缺点是分布不均匀,假如按照 1000 万来进行分表,有可能某个分段实际存储的数据量只有 1000 条,而另外一个分段实际存储的数据量有 900 万条。
Hash路由: Hash 路由的优缺点和范围路由基本相反,Hash 路由的优点是表分布比较均匀,缺点是扩充新的表很麻烦,所有数据都要重分布。
配置路由: 配置路由就是路由表,用一张独立的表来记录路由信息。同样以用户 ID 为例,我们新增一张 user_router 表,这个表包含 user_id 和 table_id 两列,根据 user_id 就可以查询对应的 table_id。
秒杀架构分析
从整体上思考问题。在我看来,秒杀其实主要解决两个问题,一个是并发读,一个是并发写。
5个架构原则
- 数据要尽量少
- 请求数要尽量少
- 路径要尽量短
所谓“路径”,就是用户发出请求到返回数据这个过程中,需求经过的中间的节点数。通常,这些节点可以表示为一个系统或者一个新的 Socket 连接(比如代理服务器只是创建 一个新的 Socket 连接来转发请求)。每经过一个节点,一般都会产生一个新的 Socket 连接。
- 依赖要尽量少
- 不要有单点
如何才能做好动静分离?
一点是提高单次请求的效率, 一点是减少没必要的请求。
“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和 URL、浏览者、时间、地域相关,以及是否含有 Cookie 等私密数据。 也就是所谓“动态”还是“静态”,并不是说数据本身是否动静,而是数据 中是否含有和访问者相关的个性化数据。
那么,怎样对静态数据做缓存呢?
- 第一,你应该把静态数据缓存到离用户最近的地方。 常见的就三种,用户浏览器里、CDN上或者在服务端的 Cache 中。
- 静态化改造就是要直接缓存HTTP 连接。 静态化改造是直接缓存 HTTP 连接而不是仅仅缓存数据,如下图所示,Web 代理服务器根据请求 URL,直接取出对应的 HTTP 响应头和响应体然后直接返回,这个响应过程简单得连 HTTP 协议都不用重新组装,甚至连 HTTP 请求头也不需要解析。
如何做动静分离的改造?
分离出动态内容之后,如何组织这些内容页就变得非常关键了。
- URL唯一化。
- 分离浏览者相关的因素。 浏览者相关的因素包括是否已登录,以及登录身份等,这些相关因素我们可以单独拆分出来,通过动态请求来获取。
- 分离时间因素。 服务端输出的时间也通过动态请求获取。
- 异步化地域因素。
- 去掉 Cookie。
有针对性地处理好系统的“热点数据”
热点分为热点操作和热点数据。 对系统来说,这些操作可以抽象为 “读请求”和“写请求”,这两种热点请求的处理方式大相径庭,读请求的优化空间要大一些,而 写请求的瓶颈一般都在存储层,优化的思路就是根据 CAP 理论做平衡,这个内容我在“减库存”一文再详细介绍。
发现热点数据
发现静态热点数据:
发现动态热点数据:
- 构建一个异步的系统,它可以收集交易链路上各个环节中的中间件产品的热点 Key,如 Nginx、缓存、RPC 服务框架等这些中间件(一些中间件产品本身已经有热点统计模 块)。
- 建立一个热点上报和可以按照需求订阅的热点服务的下发规范,主要目的是通过交易链 路上各个系统(包括详情、购物车、交易、优惠、库存、物流等)访问的时间差,把上 游已经发现的热点透传给下游系统,提前做好保护。比如,对于大促高峰期,详情系统 是早知道的,在统一接入层上 Nginx 模块统计的热点 URL。
- 将上游系统收集的热点数据发送到热点服务台,然后下游系统(如交易系统)就会知道 哪些商品会被频繁调用,然后做热点保护。
处理热点数据
- 优化: 优化热点数据有效的办法就是缓存热点数据,如果热点数据做了动静分 离,那么可以长期缓存静态数据。但是,缓存热点数据更多的是“临时”缓存,即不管是静 态数据还是动态数据,都用一个队列短暂地缓存数秒钟,由于队列长度有限,可以采用 LRU 淘汰算法替换。
- 限制: 限制更多的是一种保护机制,限制的办法也有很多,例如对被访问商品的 ID 做一致性 Hash,然后根据 Hash 做分桶,每个分桶设置一个处理队列,这样可以把热 点商品限制在一个请求队列里,防止因某些热点商品占用太多的服务器资源,而使其他请求 始终得不到服务器的处理资源。
- 隔离: 秒杀系统设计的第一个原则就是将这种热点数据隔离出来,不要让 1% 的请求影响到另外的 99%,隔离出来后也更方便对这 1% 的请求做针对性的优化。业务隔离、系统隔离、数据隔离
流量削峰这事应该怎么做?
服务器的处理资源是恒定的,你用或者不用它的处理能力都是一样的,所以出现峰值的话,很容易导致忙到处理不过来,闲的时候却又没有什么要处理。但是由于要保证服务质量,我们的很多处理资源只能按照忙的时候来预估,而这会导致资源的一个浪费。削峰的存在,一是可以让服务端处理变得更加平稳,二是可以节省服务器的资源成本。
- 排队
- 利用线程池加锁等待也是一种常用的排队方式;
- 先进先出、先进后出等常用的内存排队算法的实现方式;
- 把请求序列化到文件中,然后再顺序地读文件(例如基于 MySQL binlog 的同步机制) 来恢复请求等方式。
- 答题
第一个目的是防止部分买家使用秒杀器在参加秒杀时作弊。
第二个目的其实就是延缓请求,起到对请求流量进行削峰的作用
- 分层过滤
- 将动态请求的读数据缓存(Cache)在 Web 端,过滤掉无效的数据读
- 对读数据不做强一致性校验,减少因为一致性校验产生瓶颈的问题;
- 对写数据进行基于时间的合理分片,过滤掉过期的失效请求;
- 对写请求做限流保护,将超出系统承载能力的请求过滤掉;
- 对写数据进行强一致性校验,只保留最后有效的数据
影响性能的因素有哪些?又该如何提高系统的性能?
总 QPS =(1000ms / 响应时间)× 线 程数量”,这样性能就和两个因素相关了,一个是一次响应的服务端耗时,一个是处理请求 的线程数。真正对性能有影响的是 CPU 的执行时间。
线程数 = [(线程等待时间 + 线程 CPU 时间) / 线程 CPU 时间] × CPU 数量
对缓存系统而言,制约它的是内 存,而对存储型系统来说 I/O 更容易是瓶颈。这个定位的场景是秒杀,它的瓶颈更多地发生在 CPU 上。
一个办法就是看当 QPS 达到极限时,你的服务器的 CPU 使用率是不是超过了 95%,如果没有超过,那么表示 CPU 还有提升的空间,要么是有锁限制,要么是有过多的本地 I/O 等待发生。
如何优化系统
减少编码、减少序列化、Java 极致优化、并发读优化
秒杀系统“减库存”设计的核心逻辑
千万不要超卖,这是大前提。
减库存有哪几种方式
- 下单减库存
下单时直接通过数据库的事务机制控制商品库 存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
- 付款减库存
假如有 100 件商品,就可能出现 300 人下单成功的情况,因为下单时不会减库存,所以也 就可能出现下单成功数远远超过真正库存数的情况
- 预扣库存
下单时先预扣,在规定时间内不付款再释放库存,针对 恶意下单这种情况,虽然把有效的付款时间设置为 10 分钟,但是恶意买家完全可以在 10 分钟后再次下单,或者采用一次下单很多件的方式把库存减完。针对这种情况,解决办法还是要结合安全和反作弊的措施来制止。例如,给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给 某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买 3 件),以及对重复 下单不付款的操作进行次数限制等。
大型秒杀中如何减库存?
- 目前来看,业务系统中最常见的就是预扣库存方案,像你在买机票、买电影票时,下单后一 般都有个“有效付款时间”,超过这个时间订单自动释放,这都是典型的预扣库存方案。
- 由于参加秒杀的商品,一般都是“抢到就是赚到”,所以成功下单后却不付款的情况比较 少,再加上卖家对秒杀商品的库存有严格限制,所以秒杀商品采用“下单减库存”更加合理。
- “下单减库存”在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是 要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库 的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行 SQL 语句来报错;
秒杀减库存的极致优化
说一下秒杀场景下减库存的极致优化思路,包括如何在缓存中减库存以及如何在数据库中减库存。
-
秒杀商品和普通商品的减库存还是有些差异的,例如商品数量比较少,交易时间段也比较短,因此这里有一个大胆的假设,即能否把秒杀商品减库存直接放到缓存系统中实现,也就是直接在缓存中减库存或者在一个带有持久化功能的缓存系统(如 Redis)中完成呢?
如果你的秒杀商品的减库存逻辑非常单一,比如没有复杂的 SKU 库存和总库存这种联动关 系的话,我觉得完全可以。但是如果有比较复杂的减库存逻辑,或者需要使用事务,你还是必须在数据库中完成减库存。 -
单个热点商品会影响整个数据库的性能,导致 0.01% 的商品影响 99.99% 的商品的售卖,这是我们不愿意看到的情况。一个解决思路是遵循前面介绍 的原则进行隔离,把热点商品放到单独的热点库中。 但是这无疑会带来维护上的麻烦,比如 要做热点数据的动态迁移以及单独的数据库等。
-
而分离热点商品到单独的数据库还是没有解决并发锁的问题,我们应该怎么办呢?要解决并 发锁的问题,有两种办法
应用层排队、数据库层排队
- 另外,数据更新问题除了前面介绍的热点隔离和排队处理之外,还有些场景(如对商品的 lastmodifytime 字段的)更新会非常频繁,在某些场景下这些多条 SQL 是可以合并的,一定时间内只要执行最后一条 SQL 就行了,以便减少对数据库的更新操作
准备Plan B:如何设计兜底方案?
说到系统的高可用建设,它其实是一个系统工程。降级、限流和 拒绝服务。
- 降级: 降级方案可以这样设计:当秒杀流量达到 5w/s 时,把成交记录的获取从展示 20 条降级到 只展示 5 条。“从 20 改到 5”这个操作由一个开关来实现,也就是设置一个能够从开关系 统动态获取的系统参数。所以降级的核心目标是牺牲次要的功能和用户体验来保证核心业务流程的稳 定,是一个不得已而为之的举措
- 限流: 例如在远程调用时我们设置连接池的线程数,超出这个并发线 程请求,就将线程进行排队或者直接超时丢弃。
- 拒绝服务: 例如 CPU 使用率达到 90% 或者系统 load 值达到 2*CPU 核 数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的系统保护方式。
答疑解惑:缓存失效的策略应该怎么定?
- 很多用户比较关注应用层排队的问题,大家主要的疑问就是应用层用队列接受请求,然后结果怎么返回的问题。
其实我这里所说的排队,更多地是说在服务端的服务调用之间采用排队的策略。
服务端接受请求本身就是按照请求顺序处理的,而且这个处理在 Web 层是实时同步的,处理的结果也会立马就返回给用户。但是我前面也说了,整个请求的处理涉及很多服务调用也涉及很多其他的系统,也会有部分的处理需要排队,所以可能有部分先到的请求由于后面的一些排队的服务拖慢,导致最终整个请求处理完成的时间反而比较后面的 请求慢的情况。
- 大家在纠结异步请求如何返回结果的问题,其实有多种方案。
一是页面中采用轮询的方式定时主动去服务端查询结果,例如每秒请求一次服务端看看有 没有处理结果(现在很多支付页面都采用了这种策略),这种方式的缺点是服务端的请求 数会增加不少。
二是采用主动 push 的方式,这种就要求服务端和客户端保持连接了,服务端处理完请求 主动 push 给客户端,这种方式的缺点是服务端的连接数会比较多。
比如阿里的双十一并发下单支持10w的QPS,虽然是 的10w但是落到实际的数据库层多个库的多台机器上,因为我们可以根据用户请求的商品ID进行分库分表,这样可以大大减少并发度。
前端层设计
1、秒杀页面的展示
- 各类静态资源首先应分开存放,然后放到CDN节点上分散压力,由于CDN节点遍布全国各地,能缓冲掉绝大部分的压力,而且还比机房带宽便宜。
2、倒计时
- 浏览器层请求拦截:
产品层面,用户点击“查询”或者“购票”后,按钮置灰,禁止用户重复提交请求; JS层面,限制用户在x秒之内只能提交一次请求
3、 站点层设计
前端层的请求拦截,只能拦住小白用户(不过这是99%的用户哟),高端的程序员根本不吃这一套,写个for循环,直接调用你后端的http请求,怎么整?同一个uid,限制访问频度,做页面缓存,x秒内到达站点层的请求,均返回同一页面;同一个item的查询,例如手机车次,做页面缓存,x秒内到达站点层的请求,均返回同一页面。
服务层设计
-
用户请求预处理模块
经过HTTP服务器的分发后,单个服务器的负载相对低了一些,但总量依然可能很大,如果后台商品已经被秒杀完毕,那么直接给后来的请求返回秒杀失败即可,不必再进一步发送事务了。 -
服务+数据库+缓存一套”的方式提供数据访问,用cache提高读性能。不管采用主从的方式扩展读性能,还是缓存的方式扩展读性能,数据都要复制多份(主+从,db+cache),一定会引发一致性问题。
4. 直接面向MySQL之类的存储是不合适的,如果有这种复杂业务的需求,都建议采用异步写入。当然,也有一些秒杀和抢购采用“滞后反馈”,就是说秒杀当下不知道结果,一段时间后才可以从页面中看到用户是否秒杀成功。但是,这种属于“偷懒”行为,同时给用户的体验也不好,容易被用户认为是“暗箱操作”。
5. 重启与过载保护
6. 超卖
- 悲观锁思路
- 虽然上述的方案的确解决了线程安全的问题,但是,别忘记,我们的场景是“高并发”。也就是说,会很多这样的修改请求,每个请求都需要等待“锁”,某些线程可能永远都没有机会抢到这个“锁”,这种请求就会死在那里。同时,这种请求会很多,瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常。
- FIFO队列思路
全部请求采用“先进先出”的队列方式来处理。那么新的问题来了,高并发的场景下,因为请求很多,很可能一瞬间将队列内存“撑爆”,然后系统又陷入到了异常状态。
- 乐观锁思路
乐观锁,是相对于“悲观锁”采用更为宽松的加锁机制,大都是采用带版本号(Version)更新。实现就是,这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。
设计模式
观察者设计模式
当对象间存在一对多关系时,则使用观察者模式(Observer
Pattern)。比如,当一个对象被修改时,则会自动通知依赖它的对象。观察者模式属于行为型模式。
zookeeper的watch机制就是观者设计模式的很好体现。
观察者设模式详解
使用场景:
- 一个抽象模型有两个方面,其中一个方面依赖于另一个方面。将这些方面封装在独立的对象中使它们可以各自独立地改变和复用。
- 一个对象的改变将导致其他一个或多个对象也发生改变,而不知道具体有多少对象将发生改变,可以降低对象之间的耦合度。
- 一个对象必须通知其他对象,而并不知道这些对象是谁。
需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,可以使用观察者模式创建一种链式触发机制。
外观模式
外观模式(Facade Pattern)隐藏系统的复杂性,并向客户端提供了一个客户端可以访问系统的接口。这种类型的设计模式属于结构型模式,它向现有的系统添加一个接口,来隐藏系统的复杂性。
SLF4J是简单的日志外观模式框架,抽象了各种日志框架例如Logback、Log4j、Commons-logging和JDK自带的logging实现接口。它使得用户可以在部署时使用自己想要的日志框架。SLF4J是轻量级的,在性能方面几乎是零消耗的。
适配器模式
HBase系统架构
分布式锁
Redis实现分布式锁
操作系统
为什么进程上下文切换比线程上下文切换代价高?
- 进程切换分两步:
- 内存切换以使用新的地址空间
- 切换内核栈和硬件上下文
- 对于linux来说,线程和进程的最大区别就在于地址空间,对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。
- 切换的性能消耗:
- 线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
- 另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲(processor’s Translation Lookaside Buffer (TLB))或者相当的神马东西会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题。
CPU调度
- 吞吐量与响应时间的矛盾
响应时间少 — 切换次数多 — 系统内耗多 — 》吞吐量小- 前台任务和后台任务的关注点不同
前台任务关注响应时间,后台任务关注周转时间- IO约束型任务和CPU约束型任务
IO任务优先级应该高些
具体算法
- FCFS(公平)、短作业优先(周转时间最少)
- 为了保证响应时间:RR(时间片小:吞吐量小;时间片长大:响应时间长)
- 这里优先级调度应该动态调整。
Linux零拷贝
内存管理
系统调用
虚拟内存
IO
进程间通信
(信号量:是操作系统提供的一种协调共享资源访问的方法、
管程是一种用于多线程互斥访问共享资源的程序结构)
- 信号 Signal
进程间的软件中断通知和处理机制;
不足:传送的信息量小,只有一个信号类型
- 管道 Pipe
进程间基于内存文件的通信机制、间接通信
管道是一种半双工的通信方式,数据只能单项流动,并且只能在具有亲缘关系的进程间流动,进程的亲缘关系通常是父子进程。
命名管道也是半双工的通信方式,它允许无亲缘关系的进程间进行通信。
进程不知道(或不关心)另一端,可能从键盘、文件、程序读取,可能写入到终端、文件、程序。
- 消息队列
消息队列是由操作系统维护的以字节序列为基本单位的间接通信机制
可以实现两个生命周期不同的进程进行通信。
允许任意进程通过共享消息队列来实现进程间通信.并由系统调用函数来实现消息发送和接收之间的同步.从而使得用户在使用消息缓冲进行通信时不再需要考虑同步问题.使用方便,但是信息的复制需要额外消耗CPU的时间.不适宜于信息量大或操作频繁的场合。
- 共享内存
共享内存是把同一个物理内存区域同时共映射到多个进程的内存地址空间的通信机制。
每个进程都私有内存地址空间,每个进程的内存地址空间必须明确设置共享内存段,而同一进程中的线程总是共享相同的内存地址空间。
优点:快速分表,没有数据复制、没用系统调用干预。
缺点:必须用额外的同步机制来协调数据的访问。
死锁
产生死锁的原因及解决方案:
互斥:把互斥的共享资源封装成可同时访问
持有并等待:请求资源时,要求它不持有任何资源;仅允许进程开始执行时,一次请求所有需要的资源。(资源利用率低)
非抢占:如进程请求资源不能立即分配的资源,责释放已占有的资源。
循环等待:对资源排序,要求进程按顺序请求资源。
死锁避免:(银行家算法)
当进程请求资源时,系统判断分配后是否处于安全状态。
网络基础
cookie、session、token
- cookie无法防止CSRF(Cross Site Request Forgery)一般被翻译为跨站请求伪造 ,但token可以。登录成功获得 token 之后,一般会选择存放在 local storage 中。然后我们在前端通过某些方式会给每个发到后端的请求加上这个 token,这样就不会出现 CSRF 漏洞的问题。因为,即使有个你点击了非法链接发送了请求到服务端,这个非法请求是不会携带 token 的,所以这个请求将是非法的。
- JWT 本质上就一段签名的 JSON 格式的数据。由于它是带有签名的,因此接收者便可以验证它的真实性。
- token适合移动端使用
- token单点登录友好,使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 token 进行认证的话, token 被保存在客户端,不会存在这些问题。
cooike安全
- 禁止加载外域代码,防止复杂的攻击逻辑。
- 禁止外域提交,网站被攻击后,用户的数据不会泄露到外域。
- 禁止未授权的脚本执行(新特性,Google Map 移动版在使用)。
- HTTP-only Cookie: 禁止 JavaScript 读取某些敏感 Cookie,攻击者完成 XSS 注入后也无法窃取此 Cookie。
- 验证码:防止脚本冒充用户提交危险操作。
DNS域名解析
- 域名结构
级别低的域写在最左面,级别高的域名写在最右面。
类似树的结构,根root,根的下一级是顶级域名,顶级域名可划分为子域。
- 主机向本地域名服务器的查询一般采用递归查询,如果主机向本地域名服务器查询不知道的IP地址,那么本地域名服务器会以DNS客服的身份,向其他根域名服务器继续发出查询请求报文。(即替该主机继续查询,)而不是让该主机自己进行下一步查询。
- 本地域名服务器向根域名服务器查询一般都是迭代查询,当根域名服务器收到本地域名服务器所要的查询IP地址,要么给出查询地址,要么告诉本地域名服务器下一步向哪个顶级域名服务器查询,顶级域名服务器告诉本地域名服务器在收到本地域名服务器的查询请求后,要么给出查询IP地址,要么告诉本地域名服务器向哪个权限域名服务器查询。
HTTP
HTTP协议格式详解
HTTP缓存机制
HTTP缓存补充
面试官问你Http2.0
HTTP中Get Post Put区别
HTTPS建立连接过程
- Client发送报文开始SSL通信,报文中包含客户端支持SSL版本、加密组件、加密算法等。
- 服务端回复一个招呼报文包含自己支持的SSL版本,加密算法等信息。
- 服务端发送自己经过CA认证的公开密钥(FPkey),CA认证机构使用自己的私有密钥给FPkey加上签名返回给服务端
- 服务端发送结束招呼的报文,SSL第一次握手(握手协商部分)结束。
- 客户端使用FPkey对自己的随机密码串(Ckey)进行加密并发送给服务端
- 客户端发送提示报文,后续报文将用Ckey进行加密
- 客户端发送finished报文,表示该次发送结束。后续是否通信取决于客户端的finished报文能否被服务端成功解密
- 服务端发送提示报文,表示他之后的报文也会用Ckey进行加密
- 服务端发送finished报文。至此SSL握手结束,成功建立SSL连接。
- 客户端开始发送http请求报文,建立Tcp连接,开始传输数据,服务端发送http回复报文,客户端发送断开连接报文,并断开Tcp连接
输入url过程
- 浏览器解析url之后,浏览器确定了Web 服务器和文件名,接下来就是根据这些信息来生成HTTP请求消息了。
- DNS(Socket UDP) 寻找ip(从根域一级级的查询、设置缓存存在有效期),利用Socket组件
- TCP按照网络包的长度对数据进行拆分,在每个包前面加上TCP头部并转交给IP
- IP在TCP包前面加上IP头部,然后查询MAC地址并加上MAC头部,然后将包交给网卡驱动
- 服务器端的局域网有防火墙,对进入的包进行检查,判断是否通过
- Web服务器前面如果有缓存服务器,回拦截防火墙的包,如果用户请求页面已经缓存,则代替服务器向用户返回页面数据
- 没被缓存,Web服务器收到包后,网卡和网卡驱动交给协议栈,以此检查IP、TCP头部,取出HTTP信息数据块进行组装,通过Socket库交给Web服务器,分析HTTP内容,将数据返回给客户端。
- 处理 HTML 标记并构建 DOM 树、处理 CSS 标记并构建 CSSOM 树、将 DOM 与 CSSOM 合并成一个渲染树、根据渲染树来布局,以计算每个节点的几何信息、将各个节点绘制到屏幕上。
TCP可靠传输实现
可靠传输的工作原理:停止等待协议和连续ARQ
- 以字节为单位的滑动窗口
- 发送窗口表示:发送方A,在没有收到B的确认下,A可以连续把窗口内的数据都发送出去。凡是已发送过的数据,在未收到确认之前都必须暂时保留,以便在超时重传使用。
- 发送窗口的位置由窗口前沿和后沿的位置共同确定。
- 接收窗口:对于不按序到达的数据该如何处理,TCP标准并无明确确定,如果接收方直接丢弃,管理简单但是会浪费网络资源。所以大多是对不按序到达的数据先临时保存在接收窗口,等到字节流中缺少字节补充后,交给应用层。
- TCP要求接收方必须有累计确认功能。
- 超时重传时间的选择
报文段的往返时间RTT,超时重传时间RTO,这里面设计一种自适应算法,
例如发出一个报文段,设定重传时间到了,还没收到确认,那么会重发,经过一段时间,收到确认的报文,不好区分是确认哪个报文的。所以对算法进行修正,报文段重传一次,就把超时重传时间RTO增大一些。典型做法是取新的重传时间为2倍的旧重传时间,当不发生报文重传时,重新计算超时重传时间。
- 选择确认SACK
RFC2018有明确规定,并没有指明发送方应当怎样响应SACK。
tcp time wait过多怎么办
TCP网络常问点
关于TCP/IP常见知识点
以上补充
关于IP分片了解下
UDP变可靠
Time Wait过多解决办法
Java基础
Java 提供的默认排序算法,具体是什么排序方式以及设计思路等。
- 对于原始数据类型,目前使用的是所谓双轴快速排序(Dual-Pivot QuickSort),是一种改进的快速排序算法,早期版本是相对传统的快速排序,你可以阅读源码。
- 而对于对象数据类型,目前则是使用TimSort,思想上也是一种归并和二分插入排序(binarySort)结合的优化排序算法。TimSort 并不是 Java 的独创,简单说它的思路是查找数据集中已经排好序的分区(这里叫 run),然后合并这些分区来达到排序的目的。
- 另外,Java 8 引入了并行排序算法(直接使用 parallelSort 方法),这是为了充分利用现代多核处理器的计算能力,底层实现基于 fork-join 框架(专栏后面会对 fork-join 进行相对详细的介绍),当处理的数据集比较小的时候,差距不明显,甚至还表现差一点;但是,当数据集增长到数万或百万以上时,提高就非常大了,具体还是取决于处理器和系统环境。
- Java 8 在语言层面的新特性,允许接口实现默认方法。
- 在 Java 9 中,Java 标准类库提供了一系列的静态工厂方法,比如,List.of()、Set.of(),大大简化了构建小的容器实例的代码量。
java线程池
线程池中断处理
关闭线程池_1
关闭线程池_2
shutdownNow方法的解释是:线程池拒接收新提交的任务,同时立马关闭线程池,线程池里的任务不再执行。
shutdown方法的解释是:线程池拒接收新提交的任务,同时等待线程池里的任务执行完毕后关闭线程池。
调用完shutdownNow和shuwdown方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用awaitTermination方法进行同步等待。
threadPool.shutdown(); // Disable new tasks from being submitted
// 设定最大重试次数
try {
// 等待 60 s
if (!threadPool.awaitTermination(60, TimeUnit.SECONDS)) {
// 调用 shutdownNow 取消正在执行的任务
threadPool.shutdownNow();
// 再次等待 60 s,如果还未结束,可以再次尝试,或者直接放弃
if (!threadPool.awaitTermination(60, TimeUnit.SECONDS))
System.err.println("线程池任务未正常执行结束");
}
} catch (InterruptedException ie) {
// 重新调用 shutdownNow
threadPool.shutdownNow();
}
阻塞队列
public SynchronousQueue(boolean fair) {
//公平模式下使用队列,实现先进先出,非公平模式下使用栈,先进后出
transferer = fair ? new TransferQueue<E>() : new TransferStack<E>();
}
SynchronousQueue是一个无空间的队列即不可以通过peek来获取数据或者contain判断数据是否在队列中。
如果新请求与队列tail节点的模式相同,则将请求加入队列,模式不同,则可进行消费从队列中移除节点。
- SynchronousQueue不存储数据,只存储请求
- 当生产或消费请求到达时,如果队列中没有互补的请求,则将会此请求加入队列中,线程进入阻塞等待互补的请求到达。
- 若是互补的请求到达时,则唤醒队列中的线程,消费请求使用生产请求中的数据内容。
AQS
java类加载机制
final
final域的重排序规则:
- 在构造函数对一个final域的写入,与随后把这个构造函数的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含一个final域对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
集合补充
Collections 继承 Iterable
CopyOnWriteArrayList
Set — SortedSet — TreeSet
ConcurrentSkipListSet
ArrayDeque是Deque的实现类,可以作为栈来使用,效率高于Stack;也可以作为队列来使用,效率高于LinkedList。需要注意的是,ArrayDeque不支持null值。
Map
ConcurrentHashMap transfer 源码分析
深入理解HashMap+ConcurrentHashMap的扩容策略
Java 8系列之重新认识HashMap
ConcurrentHashMap
ConcurrentHashMap size 改为mappingCount
内部类和静态内部类的区别
内部类:
1、内部类中的变量和方法不能声明为静态的。
2、内部类实例化:B是A的内部类,实例化B:A.B b = new A().new B()。
3、内部类可以引用外部类的静态或者非静态属性及方法。
静态内部类:
1、静态内部类属性和方法可以声明为静态的或者非静态的。
2、实例化静态内部类:B是A的静态内部类,A.B b = new A.B()。
3、静态内部类只能引用外部类的静态的属性及方法。
inner classes——内部类
static nested classes——静态嵌套类
其实人家不叫静态内部类,只是叫习惯了,从字面就很容易理解了。
内部类依靠外部类的存在为前提,而静态嵌套类则可以完全独立,明白了这点就很好理解了。
threadLocal
- 父线程
创建线程的当前线程就是新线程的父线程,新线程的一些资源来自于这个父线程,借助于当前正在运行的线程,对新创建线程进行一些必要的赋值与初始化。
- InheritableThreadLocal
InheritableThreadLocal继承了ThreadLocal,
如何中断线程
- 自定义一个Boolean类型的中断标志位,提供一个中断方法,线程一直循环检测该标志位,标志位被设置为退出状态是终止线程。
- 。。。。。。
application/json 四种常见的 POST 提交数据方式
application/json 四种常见的 POST 提交数据方式
Java动态代理
关于AOP无法切入同类调用方法的问题
Object.wait/notify
接口和抽象方法
-
抽象类可以有构造方法,接口中不能有构造方法。
-
抽象类中可以包含非抽象的普通方法,接口中的所有方法必须都是抽象的,不能有非抽象的普通方法。
-
抽象类中可以包含静态方法,接口中不能包含静态方法
-
抽象类和接口中都可以包含静态成员变量,抽象类中的静态成员变量的访问类型可以任意,但接口中定义的变量只能是public static final类型,并且默认即为public static final类型。
-
一个类可以实现多个接口,但只能继承一个抽象类
-
都不能被实例化
-
接口的实现类或抽象类的子类都只有实现了接口或抽象类中的方法后才能实例化。
-
Java1.8接口新增 只能定义default和static类型的方法。
Spring
Spring初始化过程
1、首先初始化上下文,生成ClassPathXmlApplicationContext对象,在获取resourcePatternResolver对象将xml解析成Resource对象。
2、利用1生成的context、resource初始化工厂,并将resource解析成beandefinition,再将beandefinition注册到beanfactory中。
具体描述(个人总结):
applicationContext.xml, 这是个资源文件,由于我们的bean都在里边进行配置定义,那Spring总得对这个文件进行读取并解析。
- Resource表示资源的抽象(策略模式)
- ResourceLoader组件,该组件负责对Spring资源的加载,资源指的是xml、properties等文件资源,返回一个对应类型的Resource对象。
- ApplicationContext,AbstractApplication是实现了ResourceLoader的,这说明什么呢?说明我们的应用上下文ApplicationContext拥有加载资源的能力,这也说明了为什么可以通过传入一个String resource path给ClassPathXmlApplicationContext(“applicationContext.xml”)就能获得xml文件资源的原因了。
- 我们拥有了加载器ResourceLoader,也拥有了对资源的描述Resource,但是我们在xml文件中声明的bean/>标签在Spring又是怎么表示的呢?注意这里只是说对bean的定义,而不是说如何将bean/>转换为bean对象。于是就引入一个叫BeanDefinition的组件。
- 我们的Resource资源是怎么转成我们的BeanDefinition的呢?因此就引入了BeanDefinitionReader组件,。
- 你有了BeanDefinition后,你还必须将它们注册到工厂中去,所以当你使用getBean()方法时工厂才知道返回什么给你。还有一个问题既然要保存注册这些bean,那肯定要有个数据结构充当容器吧!没错,就是一个Map。BeanDefinitionRegistry
- ApplicationContext上下文基本直接或间接贯穿所有的部分,因此我们一般称之为容器,除此之外,ApplicationContext还拥有除了bean容器这种角色外,还包括了获取整个程序运行的环境参数等信息(比如JDK版本,jre等),其实这部分Spring也做了对应的封装,称之为Enviroment。
- 调用super(parent)方法为容器设置好Bean资源加载器,该方法最终会调用到AbstractApplicationContext的无参构造方法
- setConfigLocations(configLocations)设置Bean定义资源文件的定位路径
- ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();。这句代码的作用是告诉子类启动refreshBeanFactory方法以及通过getBeanFactory获得beanFactory。
protected ConfigurableListableBeanFactory obtainFreshBeanFactory() { this.refreshBeanFactory(); return this.getBeanFactory(); }
- refreshBeanFactory: 创建一个新的bean工厂(createBeanFactory())->将所有BeanDefinition载入beanFactory中,此处依旧是模板方法,具体由子类实现(loadBeanDefinitions(beanFactory); )
IOC
IOC控制反转可以理解为面向对象的一种补充。IOC主要实现策略依赖查找、依赖注入。
Spring事务失效
透彻的掌握 Spring 中@transactional 的使用
- 数据库引擎不支持事务
- @Transactional 只能应用到 public 方法才有效
- 在 Spring 的 AOP 代理下,只有目标方法由外部调用,目标方法才由 Spring 生成的代理对象来管理,这会造成自调用问题。
BeanFactory和FactoryBean
Leetcode
int[][] dp = new int[n + 1][n + 1];
for (int len = 2; len <= n; len++) { //区间长度
for (int i = 1; i <= n - len + 1; i++) { //区间起点
int j = i + len - 1; //区间终点
for (int k = i; k < j; k++) {
dp[i][j] = Math.min(dp[i][j],
dp[i][k] + dp[k + 1][j] +
preSum[j] - preSum[i - 1]);
}
}
}
System.out.println(dp[1][n]); //dp[1][n]
背包问题
MySQL
MySQL InnoDB MVCC 机制的原理及实现
MVCC最大的优势:读不加锁,读写不冲突。在读多写少的场景下,读写不冲突是非常重要的,极大的增加了系统的并发性能
- InnoDB MVCC 实现原理
- InnoDB 中 MVCC 的实现方式为:每一行记录都有两个隐藏列:DATA_TRX_ID、DATA_ROLL_PTR(如果没有主键,则还会多一个隐藏的主键列)。
- 形成undo log链
- 实现一致性读 —— ReadView,解决版本链中哪些版本对当前事务可见。
- 总结
RC、RR 两种隔离级别的事务在执行普通的读操作时,通过访问版本链的方法,使得事务间的读写操作得以并发执行,从而提升系统性能。RC、RR 这两个隔离级别的一个很大不同就是生成 ReadView 的时间点不同,RC 在每一次 SELECT 语句前都会生成一个 ReadView,事务期间会更新,因此在其他事务提交前后所得到的 m_ids 列表可能发生变化,使得先前不可见的版本后续又突然可见了。而 RR 只在事务的第一个 SELECT 语句时生成一个 ReadView,事务操作期间不更新。
Redis 与MySQL双写一致性方案解析
MySQL索引背后的数据结构及算法原理
MySQL引起CPU消耗过大,怎么办?
MySql行锁
死锁和死锁检测:
- 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
在 InnoDB 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。但是,我们又不可能直接把这个时间设置成一个很小的值,比如 1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。
- 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。
- 另一个思路是控制并发度
这个并发控制要做在数据库服务端。如果你有中间件,可以考虑在中间件实现;如果你的团队有能修改 MySQL 源码的人,也可以做在 MySQL 里面。基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在 InnoDB 内部就不会有大量的死锁检测工作了。
- 考虑通过将一行改成逻辑上的多行来减少锁冲突。还是以影院账户为例,可以考虑放在多条记录上,比如 10 个记录,影院的账户总额等于这 10 个记录的值的总和。这样每次要给影院账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成原来的 1/10,可以减少锁等待个数,也就减少了死锁检测的 CPU 消耗。
Mysql join
- inner left right
- 如果可以使用被驱动表的索引,join 语句还是有其优势的;
- 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
- 在使用 join 的时候,应该让小表做驱动表。
Mysq的淘宝官方日报
Mysql索引
普通索引和唯一索引的区别:change buffer 只限于用在普通索引的场景下,而不适用于唯一索引。
- 业务正确性优先。“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。如果业务不能保证,或者业务就是要求数据库来做约束,那么没得选,必须创建唯一索引。这种情况下,本篇文章的意义在于,如果碰上了大量插入数据慢、内存命中率低的时候,可以给你多提供一个排查思路。
业务场景- 对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。
- 假设一个业务的更新模式是写入之后马上会做查询,增加了 change buffer 的维护代价。所以,对于这种业务模式来说,change buffer 反而起到了副作用。
MySQL N叉树是可以调整的吗
- 调整key的大小
- 调整页的大小
MySQL 查询突然变慢
- 如果 MySQL 数据库本身就有很大的压力,导致数据库服务器 CPU 占用率很高或 ioutil(IO 利用率)很高,这种情况下所有语句的执行都有可能变慢。
- 写日志
- 可能出现wait for MDL 锁,这类问题的处理方式,就是找到谁持有 MDL 写锁,然后把它 kill 掉。
- 等 flush,出现Waiting for table flush,但是正常这两个语句执行起来都很快,除非它们也被别的线程堵住了。所以,出现 Waiting for table flush 状态的可能情况是:有一个 flush tables 命令被别的语句堵住了,然后它又堵住了我们的 select 语句
- 等行锁
- 查询慢,索引错误使用
字段函数计算、where month(t_modified) =7;
隐式类型转换、字符串数字之间相互转换
隐式字符编码转换、
- 解决方案:查看慢查询日志,扫描行数多,所以执行慢,这个很好理解。
查看慢查询日志:set long_query_time = 0
MySQL优化
- 子查询优化 优化操作子查询、join、覆盖索引
- 怎么删除表的前 10000 行
第一种方式(即:直接执行 delete from T limit 10000)里面,单个语句占用时间长,锁的时间也比较长;而且大事务还会导致主从延迟。
第三种方式(即:在 20 个连接中同时执行 delete from T limit 500),会人为造成锁冲突。
第二种方式,即:在一个连接中循环执行 20 次 delete from T limit 500。
JVM及其调优
内存泄漏
Java heap leaks(java堆泄漏), Native memory leaks(本机内存泄漏):与Java堆之外的任何不断增长的内存利用率相关联,例如由JNI代码,驱动程序甚至JVM分配。
如何发现??
- 在许多情况下,Java进程最终会抛出一个OOM运行时异常,这是一个明确的指示。在这种情况下,您需要区分正常的内存耗尽和泄漏。分析OOM的消息并尝试根据上面提供的讨论找到罪魁祸首。另一方面,并非所有内存泄漏都必然表现为OOM,特别是在桌面应用程序或客户端应用程序(没有重新启动时运行很长时间)的情况下。
- 通过 jstat -gcutil
- 使用Java VisualVM远程分析堆
如何避免??
Java中的内存泄露,广义并通俗的说,就是:不再会被使用的对象的内存不能被回收,就是内存泄露。
- 如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。
- 可以做的就是赋值为null的操作,不管GC何时会开始清理,我们都应及时的将无用的对象标记为可被清理的对象。例如在LinkedList、ArrayList中, elementData[–size] = null; // clear to let GC do its work
- 资源close try-with-resources
- threadLocal
- static
- 等等
内存回收分配策略
对象的内存分配,往大方向上说就是在堆上分配,对象主要分配在新生代的Eden区。
- 对象优先在Eden分配
- 大对象直接进入老年代
虚拟机提供一个 -XX:PretenureSizeThreshold参数,令大于这个参数的值直接在老年代分配。
- 长期存活对象直接进去老年代
- 动态年龄对象判定
如果在Survivor空间中相同年龄所有对象大小大于Survivor空间一半,年龄大等于这个的直接进入老年代。
- 空间分配担保
MinorGC、MajorGC
美团关于GC优化的案例
单次Minor GC时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到Survivor区)。当Eden区较小时,new threshold = 2(动态年龄判断,对象的晋升年龄阈值为2),对象仅经历2次Minor GC后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的Major GC。 2. Major GC后老年代使用空间为300M+,意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活,也就是说生命周期长的对象占比很小。
CMS
CMS是一种获取最短回收停顿时间为目标的收集器,尤其重视响应速度。基于标记清除算法实现。
- 初始化标记(STW)
仅仅是标记一下 GC Roots能直接关联到的对象,速度很快。
- 并发标记
并发标记阶段就是进行GC Roots Tracing的过程。
- 重新标记(STW)
为了修正并发标记期间,因用户程序继续运作导致标记产生的变动的那一部分对象的标记记录。
- 并发清除
缺点
- CMS对CPU资源比较敏感
CMS收集器默认启动回收线程数是(CPU数量+3)/ 4,也就是4个CPU以上时,并发回收收集器最多不占用超过25%的CPU资源,但是如果CPU数量不足4个的时候,CMS对用户程序就很大,会降低程序运行速度。为了解决这种情况,虚拟机提出一种增量式并发收集器,让GC线程和用户线程交替运行,尽量减少GC线程的独占资源的时间。
- CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failture”失败导致另一次Full GC产生。
由于CMS并发清理阶段用户线程依然运行,伴随程序运行垃圾不断产生,这一部分垃圾只能留在下次回收,就是“浮动垃圾”。CMS收集器需要留一部分空间给并发收集时用户程序使用,会有个参数-XX:CMSInitiatingOccupancyFraction,用来调节触发比。要是CMS运行期间预留内存无法满足程序运行需要,那就会进行SerialOld GC。
- 产生大量空间碎片。
基于标记-清除算法。
G1垃圾收集器
基于标记-整理算法实现的收集器,也就是说不会产生空间碎片。
G1垃圾收集器可以实现基本不牺牲吞吐量的前提下,完成低停顿的内存回收,这是由于它能够极力避免全区域的垃圾收集,之前的垃圾收集器收集的范围时整个新生代或老年代,而G1将整个Java堆划分多个大小固定的独立区域(Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域。
ZGC
ZGC是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:
- 停顿时间不超过10ms;
- 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
- 支持8MB~4TB级别的堆(未来支持16TB)。
1. ZGC为什么会出现,比CMS、G1垃圾收集哪里强?
- CMS新生代的Young GC、G1和ZGC都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。
- 标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:
- 标记阶段,即从GC Roots集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
- 转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题
2. ZGC对该算法做了重大改进
- ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
- ZGC通过着色指针和读屏障技术
- 在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。
- 着色指针是一种将信息存储在指针中的技术,ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
垃圾收集器
Full GC 触发条件
Minor GC ,Full GC 触发条件
Minor GC触发条件:当Eden区满时,触发Minor GC。
Full GC触发条件:
(1)调用System.gc时,系统建议执行Full GC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5)由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
(6)CMS垃圾回收器两个参数控制FullGC。
一个导致频繁GC的鬼–数组动态扩容
堆外内存GC完全解读
Java线上故障排查套路
FullGC案例一
FullGC案例二
FullGC案例三
Java故障排查
GC问题诊断
内存持续上升,我该如何排查问题?
- cpu占用过高排查思路
top 查看占用cpu的进程 pid
top -Hp pid 查看进程中占用cpu过高的线程id tid
printf ‘%x/n’ tid 转化为十六进制
jstack pid |grep tid的十六进制 -A 30 查看堆栈信息定位
- jvm old区占用过高排查思路
top查看占用cpu高的进程
jstat -gcutil pid 时间间隔 查看gc状况
jmap -dump:format=b,file=name.dump pid 导出dump文件
用visualVM分析dump文件
class文件常量池、运行时常量池、String常量池
Java并发相关
CompletableFuture基本用法
在Java8中,CompletableFuture提供了非常强大的Future的扩展功能,可以帮助我们简化异步编程的复杂性,并且提供了函数式编程的能力,可以通过回调的方式处理计算结果,也提供了转换和组合 CompletableFuture 的方法。
一个线程start多次?
CAS、ReentrantLock的原理
ReentrantLock如何实现公平和非公平
- 非公平锁在调用 lock 后,首先就会调用 CAS 进行一次抢锁,如果这个时候恰巧锁没有被占用,那么直接就获取到锁返回了。
- 非公平锁在 CAS 失败后,和公平锁一样都会进入到 tryAcquire 方法,在 tryAcquire 方法中,如果发现锁这个时候被释放了(state == 0),非公平锁会直接 CAS 抢锁,但是公平锁会判断等待队列是否有线程处于等待状态,如果有则不去抢锁,乖乖排到后面(hasQueuedProcessors)。
Java中的join
阻塞队列、消息队列
- 锁机制实现不同,ArrayBlcokingQueue生产和消费使用的是同一把锁,并没有做锁分离;LinkedBlockingQueue中生产、消费分别通过putLock与takeLock保证同步,进行了锁的分离;
- 一般认为前者基于数组实现,初始化后不需要再创建新的对象,但没有进行锁分离,所以内存GC压力较小,但性能会相对较低。
- 后者基于链表实现,每次都需要创建 一个node对象,会存在频繁的创建销毁操作,GC压力较大,但插入和删除数据是不同的锁,进行了锁分离,性能会相对较好;
- 从测试结果上看,其实两者性能和GC上差别都不大,在实际运用过程中,我认为一般场景下ArrayBlcokingQueue的性能已经足够应对,处于对GC压力的考虑,及潜在的OOM的风险我建议普通情况下使用ArrayBlcokingQueue即可。
- ArrayBlockingQueue、LinkedBlockingQueue、Disruptor比较
美团出品高性能Disruptor
Tomcat类加载和Java类加载区别
plusar
- Pulsar 的消息消费模型:producer-topic-subscription-consumer 消费模型。
- 在 Pulsar 的消息消费模型中,Topic 是用于发送消息的通道。每一个 Topic 对应着 Apache BookKeeper 中的一个分布式日志。发布者发布的每条消息只在 Topic 中存储一次;存储的过程中,BookKeeper 会将消息复制存储在多个存储节点上;Topic 中的每条消息,可以根据消费者的订阅需求,多次被使用,每个订阅对应一个消费者组(Consumer Group)。
- Pulsar 的分层架构 存储和计算分离
- pache Pulsar 和其他消息系统最根本的不同是采用分层架构。 Apache Pulsar 集群由两层组成:无状态服务层,由一组接收和传递消息的 Broker 组成;以及一个有状态持久层,由一组名为 bookies 的 Apache BookKeeper 存储节点组成,可持久化地存储消息。
- 即时扩展,无需数据迁移
- 由于消息服务和消息存储分为两层,因此将主题分区从一个 Broker 移动到另一个 Broker 几乎可以瞬时内完成,而无需任何数据重新平衡(将数据从一个节点重新复制到另一个节点)。
- 无缝 Broker 故障恢复
Zookeeper
- 注册中心是 CP 还是 AP 系统?
zookeeper在选举leader时,会停止服务,直到选举成功之后才会再次对外提供服务,这个时候就说明了服务不可用,但是在选举成功之后,因为一主多从的结构,zookeeper在这时还是一个高可用注册中心,只是在优先保证一致性的前提下,zookeeper才会顾及到可用性。但是在在实践中,注册中心不能因为自身的任何原因破坏服务之间本身的可连通性,这是注册中心设计应该遵循的铁律!
- 服务规模、容量、服务联通性
在服务发现和健康监测场景下,随着服务规模的增大,无论是应用频繁发布时的服务注册带来的写请求,还是刷毫秒级的服务健康状态带来的写请求,还是恨不能整个数据中心的机器或者容器皆与注册中心有长连接带来的连接压力上,ZooKeeper 很快就会力不从心,而 ZooKeeper 的写并不是可扩展的,不可以通过加节点解决水平扩展性问题。
- 注册中心需要持久存储和事务日志么?
我们知道 ZooKeeper 的 ZAB 协议对每一个写请求,会在每个ZooKeeper节点上保持写一个事务日志,同时再加上定期的将内存数据镜像(Snapshot)到磁盘来保证数据的一致性和持久性,以及宕机之后的数据可恢复,这是非常好的特性,但是我们要问,在服务发现场景中,其最核心的数据-实时的健康的服务的地址列表真的需要数据持久化么?对于这份数据,答案是否定的。
- Service Health Check
使用 ZooKeeper 作为服务注册中心时,服务的健康检测常利用 ZooKeeper 的 Session 活性 Track机制 以及结合 Ephemeral ZNode的机制,简单而言,就是将服务的健康监测绑定在了 ZooKeeper 对于 Session 的健康监测上,或者说绑定在TCP长链接活性探测上了。这在很多时候也会造成致命的问题,ZK 与服务提供者机器之间的TCP长链接活性探测正常的时候,该服务就是健康的么?答案当然是否定的!注册中心应该提供更丰富的健康监测方案,服务的健康与否的逻辑应该开放给服务提供方自己定义,而不是一刀切搞成了 TCP 活性检测!
- 注册中心的容灾考虑
在实践中,注册中心不能因为自身的任何原因破坏服务之间本身的可连通性,那么在可用性上,一个本质的问题,如果注册中心(Registry)本身完全宕机了,svcA 调用 svcB链路应该受到影响么?是的,不应该受到影响。
服务调用(请求响应流)链路应该是弱依赖注册中心,必须仅在服务发布,机器上下线,服务扩缩容等必要时才依赖注册中心。
这需要注册中心仔细的设计自己提供的客户端,客户端中应该有针对注册中心服务完全不可用时做容灾的手段,例如设计客户端缓存数据机制(我们称之为 client snapshot)就是行之有效的手段。另外,注册中心的 health check 机制也要仔细设计以便在这种情况不会出现诸如推空等情况的出现。
ZooKeeper的原生客户端并没有这种能力,所以利用 ZooKeeper 实现注册中心的时候我们一定要问自己,如果把 ZooKeeper 所有节点全干掉,你生产上的所有服务调用链路能不受任何影响么?而且应该定期就这一点做故障演练。
- 在粗粒度分布式锁,分布式选主,主备高可用切换等不需要高TPS 支持的场景下有不可替代的作用,而这些需求往往多集中在大数据、离线任务等相关的业务领域,因为大数据领域,讲究分割数据集,并且大部分时间分任务多进程/线程并行处理这些数据集,但是总是有一些点上需要将这些任务和进程统一协调,这时候就是 ZooKeeper 发挥巨大作用的用武之地。
但是在交易场景交易链路上,在主业务数据存取,大规模服务发现、大规模健康监测等方面有天然的短板,应该竭力避免在这些场景下引入 ZooKeeper,在阿里巴巴的生产实践中,应用对ZooKeeper申请使用的时候要进行严格的场景、容量、SLA需求的评估。
所以可以使用 ZooKeeper,但是大数据请向左,而交易则向右,分布式协调向左,服务发现向右。
Hystrix
Hystrix断路器 、服务降级、接近实时的监控,是一个用于处理分布式系统的延迟和容错的开源库。Hystrix能够保证在一个依赖出问题的情况下,不会导致整体服务失败,避免级联故障,以提高分布式系统的弹性。当某个服务单元发生故障之后,通过断路器的监控,向调用方返回一个符合预期的、可处理的备选响应(FallBack),而不是长时间的等待或者抛出调用方无法处理的异常,这样就保证服务调用方的线程不会被长时间、不必要的占用,从而避免了故障咋分布式系统的蔓延。乃至雪崩。
- 服务降级:fallback,服务器忙。请稍后再试,不让客户端等待并立刻返回一个友好提示;
哪些情况下会导致?1. 程序运行异常 2. 超时 3. 服务熔断 4. 线程池、信号量打满也会导致服务降级 - 服务熔断:break,先拉闸,然后调用服务降级的方法并返回友好提示
- 服务限流:flowlimit
OceanBase
-
OceanBase系统内部按照时间线将数据划分为基线数据和增量数据,基线数据是只读的,所有的修改更新到增量数据中,系统内部通过合并操作定期将增量数据融合到基线数据中。
-
从模块划分的角度看,OceanBase可以划分为四个模块:主控服务器RootServer、更新服务器UpdateServer、基线数据服务器ChunkServer以及合并服务器MergeServer。
-
RootServer:管理集群中的所有服务器,子表(tablet)数据分布以及副本管理。 RootServer一般为一主一备,主备之间数据强同步。
RootServer管理集群中的所有MergeServer、ChunkServer以及UpdateServer。每个集群由各自的RootServer负责数据划分、负载均衡、集群服务器管理等操作。每个集群内部同一时刻只允许一个UpdateServer提供写服务,这个UpdateServer成为主UpdateServer。这种方式通过牺牲一定的可用性获取了强一致性。RootServer通过租约(Lease)机制选择唯一的主UpdateServer,当原先的主UpdateServer发生故障后,RootServer能够在原先的租约失效后选择一台新的UpdateServer作为主UpdateServer。另外,RootServer与MergeServer&ChunkServer之间保持心跳(heartbeat),从而能够感知到在线和已经下线的MergeServer&ChunkServer机器列表。
UpdateServer:UpdateServer是集群中唯一能够接受写入的模块,每个集群中只有一个主Update-Server。UpdateServer中的更新操作首先写入到内存表,当内存表的数据量超过一定值时,可以生成快照文件并转储到SSD中。UpdateServer一般为一主一备。OceanBase支持强一致性和跨行跨表事务。
OceanBase所有写事务最终都落到UpdateServer,而UpdateServer逻辑上是一个单点,支持跨行跨表事务,实现上借鉴了传统关系数据库的做法。
-
ChunkServer:存储OceanBase系统的基线数据。基线数据一般存储两份或者三份,可配置。ChunkServer的功能包括:存储多个子表,提供读取服务,执行定期合并以及数据分发。
-
MergeServer:接收并解析用户的SQL请求,经过词法分析、语法分析、查询优化等一系列操作后转发给相应的ChunkServer或者UpdateServer。如果请求的数据分布在多台ChunkServer上,MergeServer还需要对多台ChunkServer返回的结果进行合并。客户端和MergeServer之间采用原生的MySQL通信协议,MySQL客户端可以直接访问MergeServer。
-
结合业务特点:(读多写少)OceanBase决定采用单台更新服务器来记录最近一段时间的修改增量,而以前的数据保持不变,以前的数据称为基线数据。
-
基线数据以类似分布式文件系统的方式存储于多台基线数据服务器中,每次查询都需要把基线数据和增量数据融合后返回给客户端。这样,写事务都集中在单台更新服务器上(这台服务器配置要相对较好),避免了复杂的分布式事务,高效地实现了跨行跨表事务;
-
更新服务器上的修改增量能够定期分发到多台基线数据服务器中,避免成为瓶颈,实现了良好的扩展性。
UpdateServer单点,这个问题限制了OceanBase集群的整体读写性能。
Redis
Redis数据结构以及内部实现
Redis持久化及优化
1)Redis提供了两种持久化方式:RDB和AOF。
2)RDB使用一次性生成内存快照的方式,产生的文件紧凑压缩比更高,因此读取RDB恢复速度更快。由于每次生成RDB开销较大,无法做到实时持久化,一般用于数据冷备和复制传输。
3)save命令会阻塞主线程不建议使用,bgsave命令通过fork操作创建子进程生成RDB避免阻塞。
4)AOF通过追加写命令到文件实现持久化,通过appendfsync参数可以控制实时/秒级持久化。因为需要不断追加写命令,所以AOF文件体积逐渐变大,需要定期执行重写操作来降低文件体积。
5)AOF重写可以通过auto-aof-rewrite-min-size和auto-aof-rewrite-percentage参数控制自动触发,也可以使用bgrewriteaof命令手动触发。
6)子进程执行期间使用copy-on-write机制与父进程共享内存,避免内存消耗翻倍。AOF重写期间还需要维护重写缓冲区,保存新的写入命令避免数据丢失。
7)持久化阻塞主线程场景有:fork阻塞和AOF追加阻塞。fork阻塞时间跟内存量和系统有关,AOF追加阻塞说明硬盘资源紧张。
8)单机下部署多个实例时,为了防止出现多个子进程执行重写操作,建议做隔离控制,避免CPU和IO资源竞争。
- 如何改善fork操作的耗时:
- 当Redis做RDB或AOF重写时,一个必不可少的操作就是执行fork操作创建子进程,对于大多数操作系统来说fork是个重量级错误。虽然fork创建的子进程不需要拷贝父进程的物理内存空间,但是会复制父进程的空间内存页表。
- 控制Redis实例最大可用内存,fork耗时跟内存量成正比,线上建议每个Redis实例内存控制在10GB以内。
- 合理配置Linux内存分配策略,避免物理内存不足导致fork失败,具体细节见12.1节“Linux配置优化”。
- 降低fork操作的频率,如适度放宽AOF自动触发时机,避免不必要的全量复制等。
- CPU 、内存、硬盘
不要和其他CPU密集型服务部署在一起,造成CPU过度竞争。
有写时复制机制(copy-on-write),父子进程会共享相同的物理内存页,当父进程处理写请求时会把要修改的页创建副本,而子进程在fork操作过程中共享整个父进程内存快照。
避免在大量写入时做子进程重写操作,这样将导致父进程维护大量页副本,造成内存消耗。
当开启AOF功能的Redis用于高流量写入场景时,如果使用普通机械磁盘,写入吞吐一般在100MB/s左右,这时Redis实例的瓶颈主要在AOF同步硬盘上。配置no-appendfsync-on-rewrite=yes时,在极端情况下可能丢失整个AOF重写期间的数据,需要根据数据安全性决定是否配置。
- AOF追加阻塞
1)主线程负责写入AOF缓冲区。
2)AOF线程负责每秒执行一次同步磁盘操作,并记录最近一次同步时间。
3)主线程负责对比上次AOF同步时间:
- 如果距上次同步成功时间在2秒内,主线程直接返回。
- 如果距上次同步成功时间超过2秒,主线程将会阻塞,直到同步操作完成。
通过对AOF阻塞流程可以发现两个问题:
1)everysec配置最多可能丢失2秒数据,不是1秒。
2)如果系统fsync缓慢,将会导致Redis主线程阻塞影响效率。
Redis阻塞
Redis是典型的单线程架构,所有的读写操作都是在一条主线程中完成的。当Redis用于高并发场景时,这条线程就变成了它的生命线。如果出现阻塞,哪怕是很短时间,对于我们的应用来说都是噩梦。导致阻塞问题的场景大致分为内在原因和外在原因:
- 内在原因包括:不合理地使用API或数据结构、CPU饱和、持久化阻塞等。
- 外在原因包括:CPU竞争、内存交换、网络问题等。
不合理地使用API或数据结构 Redis原生提供慢查询统计功能,执行slowlog get{n}命令可以获取最近的n条慢查询命令,默认对于执行超过10毫秒的命令都会记录到一个定长队列中,线上实例建议设置为1毫秒便于及时发现毫秒级以上的命令。如果命令执行时间在毫秒级,则实例实际QPS只有1000左右。慢查询队列长度默认128,可适当调大。(慢查询本身只记录了命令执行时间,不包括数据网络传输时间和命令排队时间,因此客户端发生阻塞异常后,可能不是当前命令缓慢,而是在等待其他命令执行。)
CPU饱和虽然采用ziplist编码后hash结构内存占用会变小,但是操作变得更慢且更消耗CPU。ziplist压缩编码是Redis用来平衡空间和效率的优化手段,不可过度使用。
持久化阻塞
Redis内存优化策略
- 降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度(例如Java为例,内置的序列化方式不尽人意,选择类似protobuff等)
- 字符串优化
Redis没有采用原生C语言的字符串类型而是自己实现了字符串结构,内部简单动态字符串(simple dynamic string,SDS)。
字符串之所以采用预分配的方式是防止修改操作需要不断重分配内存和字节数据拷贝。但同样也会造成内存的浪费。
尽量减少字符串频繁修改操作如append、setrange,改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化。
- 编码优化,ziplist编码主要目的是为了节约内存,因此所有数据都是采用线性连续的内存结构
- 控制键的数量,使用Redis时不要进入一个误区,大量使用get/set这样的API,把Redis当成Memcached使用。对于存储相同的数据内容利用Redis的数据结构降低外层键的数量,也可以节省大量内存。
同样的数据使用ziplist编码的hash类型存储比string类型节约内存。
hash-ziplist类型比string类型写入耗时,但随着value空间的减少,耗时逐渐降低。使用hash重构后节省内存量效果非常明显,特别对于存储小对象的场景,内存只有不到原来的1/5。
hash类型节省内存的原理是使用ziplist编码,如果使用hashtable编码方式反而会增加内存消耗。
ziplist长度需要控制在1000以内,否则由于存取操作时间复杂度在O(n)到O(n2)之间,长列表会导致CPU消耗严重,得不偿失。
Redis内存回收策略
1.删除过期键对象
- 惰性删除:惰性删除用于当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空,这种策略是出于节省CPU成本考虑,不需要单独维护TTL链表来处理过期键的删除。但是单独用这种方式存在内存泄露的问题,当过期键一直没有访问将无法得到及时删除,从而导致内存不能及时释放。
- 定时任务删除:Redis内部维护一个定时任务,默认每秒运行10次(通过配置hz控制)。
2.内存溢出控制策略
当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。
1)noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)。
2)volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
3)volatile-random:随机删除过期键,直到腾出足够空间为止
4)allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
4)allkeys-random:随机删除所有键,直到腾出足够空间为止。
6)volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
Error和Exception区别
- Exception 和 Error 都是继承了 Throwable 类,在 Java 中只有 Throwable 类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。
- Error 是指在正常情况下,不大可能出现的情况,绝大部分的 Error 都会导致程序(比如 JVM 自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如 OutOfMemoryError 之类,都是 Error 的子类。
- Exception 又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。前面我介绍的不可查的 Error,是 Throwable 不是 Exception。
不检查异常就是所谓的运行时异常,类似 NullPointerException、ArrayIndexOutOfBoundsException 之类,通常是可以编码避免的逻辑错误,具体根据需要来判断是否需要捕获,并不会在编译期强制要求。
checked exception是个错误?
性能角度来审视一下 Java 的异常处理机制,这里有两个可能会相对昂贵的地方:
- try-catch 代码段会产生额外的性能开销,或者换个角度说,它往往会影响 JVM 对代码进行优化,所以建议仅捕获有必要的代码段,尽量不要一个大的 try 包住整段的代码;与此同时,利用异常控制代码流程,也不是一个好主意,远比我们通常意义上的条件语句(if/else、switch)要低效。
- Java 每实例化一个 Exception,都会对当时的栈进行快照,这是一个相对比较重的操作。如果发生的非常频繁,这个开销可就不能被忽略了。
Kafka
- 持久化,kafka不利用JVM,而是利用OS pageCache
- Zero Copy
- Produce支持批量发送,这过程还可以对数据进行排序,减少随即写。Producer支持End-to-End的压缩。
- kfka内存缓冲池:让整个发送过程中的存储空间循环利用,有效减少JVM GC造成的影响,从而提高发送性能,提升吞吐量。kafkaProducer对象来调用发送方法,最后发送的数据根据Topic和分区的不同被组装进某一个RecordBatch中。发送的数据放入RecordBatch后会被发送线程批量取出组装成ProduceRequest对象发送给Kafka服务端。
- Partition: Partition的数量并不是越多越好,Partition的数量越多,平均到每一个Broker上的数量也就越多。考虑到Broker宕机(Network Failure, Full GC)的情况下,需要由Controller来为所有宕机的Broker上的所有Partition重新选举Leader,假设每个Partition的选举消耗10ms,如果Broker上有500个Partition,那么在进行选举的5s的时间里,对上述Partition的读写操作都会触发LeaderNotAvailableException。
源码
日志段:保存消息文件的对象是怎么实现的?
一个Kafka主题由多个分区,一个分区对应一个Log对象,比如创建主题test-topic,那么kafka会创建两个子目录test-topic-0、test-topic-1,Kafka 日志对象由多个日志段对象(LogSegment)组成,而每个日志段对象会在磁盘上创建一组文件。
- Kafka日志对象包括多个日志段对象,消息日志、索引文件、时间戳索引文件、中止事物的索引文件(不开启事务,是不会存在的。)
- 一个分区对应一个log对象。
- 源码是kafka/log/LogSegement.scala,LogSegment class 和 object。在 Scala 语言里,在一个源代码文件 中同时定义相同名字的 class 和 object 的用法被称为伴生(Companion)。Class 对象被称为伴生类,它和 Java 中的类是一样的;而 Object 对象是一个单例对象,用于保存一些静态变量或静态方法。如果用 Java 来做类比的话,我们必须要编写两个类才能实现,这两 个类也就是 LogSegment 和 LogSegmentUtils。在 Scala 中,你直接使用伴生就可以了。
- FileRecords是实际保存消息的对象,indexIntervalBytes(LogSegment的属性)是Broker端参数log.index.interval.bytes,控制日志段对象新增索引项的频率。默认,4KB新增一条,rollJitterMS设置日志段对象新增倒计时的索引值。有了 rollJitterMs 值的干扰,每个新增日志段在创建时会彼此岔开一小段时间,这样可以缓解物理磁盘的 I/O 负载瓶颈。 对于一个日志段(LogSegmetn)而言,最重要的方法就是写入消息和读取消息了,分别为append 和read方法。recover是Kafka重启恢复日志的逻辑。
- recover 方法源码。什么是恢复日志段呢?其实就是说, Broker 在启动时会从磁盘上加载所有日志段信息到内存中,并创建相应的 LogSegment 对象实例。需要读取大量的磁盘文件,可能会导致kafka重启会很慢。
- 每个日志段对象保存自己的起始位移 baseOffset,在磁盘看到的文件名就是baseOffset的值。
日志(上):日志究竟是如何加载日志段的
(log对象)日志是日志段的容器,里面定义了很多管理日志段的操作。
分布式事务
事务,其实是包含一系列操作的、一个有边界的工作序列,有明确的开始和结束标志,且要么被完全执行,要么完全失败,即 all or nothing。通常情况下,我们所说的事务指的都是本地事务,也就是在单机上的事务。
分布式事务就是在分布式系统中运行的事务,由多个本地事务组合而成。
- 分布式事务特征
ACID
分布式事务基本能够满足 ACID,其中的 C 是强一致性,也就是所有操作均执行成功,才提交最终结果,以保证数据一致性或完整性。但随着分布式系统规模不断扩大,复杂度急剧上升,达成强一致性所需时间周期较长,限定了复杂业务的处理。为了适应复杂业务,出现了 BASE 理论,该理论的一个关键点就是采用最终一致性代替强一致性。
如何实现分布式事务
- 基于 XA 协议的二阶段提交方法
两阶段提交协议的执行过程,分为投票(voting)和提交(commit)两个阶段。
- 协调者(Coordinator,即事务管理器)会向事务的参与者发起执行操作的 CanCommit 请求,并等待参与者的响应。参与者接收到请求后,会执行请求中的事务操作,记录日志信息但不提交,待参与者执行成功,则向协调者发送“Yes”消息,表示同意操作;若不成功,则发送“No”消息,表示终止操作。
- 当所有的参与者都返回了操作结果(Yes 或 No 消息)后,系统进入了提交阶段。在提交阶段,协调者会根据所有参与者返回的信息向参与者发送 DoCommit 或 DoAbort 指令。
- 两阶段提交不足
- 同步阻塞问题:二阶段提交算法在执行过程中,所有参与节点都是事务阻塞型的。也就是说,当本地资源管理器占有临界资源时,其他资源管理器如果要访问同一临界资源,会处于阻塞状态。
- 单点故障问题:基于 XA 的二阶段提交算法类似于集中式算法,一旦事务管理器发生故 障,整个系统都处于停滞状态。尤其是在提交阶段,一旦事务管理器发生故障,资源管理 器会由于等待管理器的消息,而一直锁定事务资源,导致整个系统被阻塞。
- 数据不一致问题:在提交阶段,当协调者向参与者发送 DoCommit 请求之后,如果发生 了局部网络异常,或者在发送提交请求的过程中协调者发生了故障,就会导致只有一部分 参与者接收到了提交请求并执行提交操作,但其他未接到提交请求的那部分参与者则无法 执行事务提交。于是整个分布式系统便出现了数据不一致的问题。
- 三阶段提交方法
对二阶段提交(2PC)的 改进, 为了解决两阶段提交的同步阻塞和数据不一致问题三阶段提交引入了超时机制和准备阶段。这样三阶段提交协 议就有 CanCommit、PreCommit、DoCommit 三个阶段。
- 基于分布式消息的最终一致性方案
2PC 和 3PC 这两种方法,有两个共同的缺点,一是都需要锁定资源,降低系统性能;二 是,没有解决数据不一致的问题。
在 eBay 的分布式系统架构中,架构师解决一致性问题的核心思想就是:将需要分布式处理的事务通过消息或者日志的方式异步执行,消息或日志可以存到本地文件、数据库或消息队 列中,再通过业务规则进行失败重试。这个案例,就是使用基于分布式消息的最终一致性方 案解决了分布式事务的问题。
- 总结
通过牺牲强一致性,保证最终一致性,来获得高可用性,是对 ACID 原则的弱化。这三种分布式事务实现方式,二阶段提交、三阶段提交方法,遵循的是 ACID 原则,而消息最终一致性方案遵循的就是 BASE 理论。
分布式协议与算法
CAP理论
一致性(Consistency)、可用性(Availability)、分区容错性(Partition Tolerance)
Base理论
BASE 理论是对 CAP 中一致性和可用性权衡的结果, 它来源于对大规模互联网分布式系统实践的总结,是基于 CAP 定理逐步演化而来的。它的核心思想是,如果不是必须的话,不推荐实现事务或强一致性,鼓励可用性和性能优先,根据业务的场景特点,来实现非常弹性的基本可用,以及实现数据的终一致性。
Paxos算法
一个是 Basic Paxos 算法,描述的是多节点之间如何就某个值(提案 Value)达成共识; 另一个是 Multi-Paxos思想,描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。说白了,Multi-Paxos 就是多执行几次 Basic Paxos。 在 Basic Paxos 中,有提议者(Proposer)、接受者(Acceptor)、学习者(Learner) 三种角色。
准备(Prepare)阶段
- Proposer提案者:负责提出 proposal,每个提案者在提出提案时都会首先获取到一个 具有全局唯一性的、递增的提案编号N,即在整个集群中是唯一的编号 N,然后将该编号赋予其要提出的提案,在第一阶段是只将提案编号发送给所有的表决者。
- Acceptor表决者:每个表决者在 accept 某提案后,会将该提案编号N记录在本地,这样每个表决者中保存的已经被 accept 的提案中会存在一个编号最大的提案,其编号假设为 maxN。每个表决者仅会 accept 编号大于自己本地 maxN 的提案,在批准提案时表决者会将以前接受过的最大编号的提案作为响应反馈给 Proposer 。
接受(Accept)阶段
- 当一个提案被 Proposer 提出后,如果 Proposer 收到了超过半数的 Acceptor 的批准(Proposer 本身同意),那么此时 Proposer 会给所有的 Acceptor 发送真正的提案(你可以理解为第一阶段为试探),这个时候 Proposer 就会发送提案的内容和提案编号。
- 表决者收到提案请求后会再次比较本身已经批准过的最大提案编号和该提案编号,如果该提案编号 大于等于 已经批准过的最大提案编号,那么就 accept 该提案(此时执行提案内容但不提交),随后将情况返回给 Proposer 。如果不满足则不回应或者返回 NO 。
- 当 Proposer 收到超过半数的 accept ,那么它这个时候会向所有的 acceptor 发送提案的提交请求。需要注意的是,因为上述仅仅是超过半数的 acceptor 批准执行了该提案内容,其他没有批准的并没有执行该提案内容,所以这个时候需要向未批准的 acceptor 发送提案内容和提案编号并让它无条件执行和提交,而对于前面已经批准过该提案的 acceptor 来说 仅仅需要发送该提案的编号 ,让 acceptor 执行提交就行了。
Multi-Paxos算法
Basic Paxos 只能就单个值(Value)达成共识,一旦遇到为一系列的值实现共识的时候,它就不管用了。
而如果我们直接通过多次执行 Basic Paxos 实例,来实现一系列值的共识,就会存在这样 几个问题:1. 提案冲突 2. 2轮RPC通讯延迟大
那么如何解决上面的 2 个问题呢?可以通过引入领导者和优化 Basic Paxos 执行来解决, 咱们首先聊一聊领导者。
- 我们可以通过引入领导者节点,也就是说,领导者节点作为唯一提议者,这样就不存在多个提议者同时提交提案的情况,也就不存在提案冲突的情况了。
- 优化 Basic Paxos 执行
我们可以采用“当领导者处于稳定状态时,省掉准备阶段,直接进入接受阶段”这个优化机制,优化 Basic Paxos 执行。也就是说,领导者节点上,序列中的命令是新的,不再需要通过准备请求来发现之前被大多数节点通过的提案,领导者可以独立指定提案中的值。这时,领导者在提交命令时,可以省掉准备阶段,直接进入到接受阶段
Raft算法
从本质上说,Raft 算法是通过一切以领导者为准的方式,实现一系列值的共识和各节点日志的一致
Raft 算法支持领导者(Leader)、跟随者 (Follower)和候选人(Candidate) 3 种状态。
如何选举领导者
Raft 算法实现了随机超时时间的特性。也就是说,每个节点等待领导者节点心跳信息的超时时间间隔是随机的。集群中没有领导者,而节点 A 的等待 超时时间小(150ms),它会先因为没有等到领导者的心跳信息,发生超时。这个时候,节点 A 就增加自己的任期编号,并推举自己为候选人,先给自己投上一张选 票,然后向其他节点发送请求投票 RPC 消息,请它们选举自己为领导者。如果候选人在选举超时时间内赢得了大多数的选票,那么它就会成为本届任期内新的领导者。节点 A 当选领导者后,他将周期性地发送心跳消息,通知其他服务器我是领导者,阻止跟随者发起新的选举,篡权。
在一次选举中,每一个服务器节点多会对一个任期编号投出一张选票,并且按照“先 来先服务”的原则进行投票。比如节点 C 的任期编号为 3,先收到了 1 个包含任期编号 为 4 的投票请求(来自节点 A),然后又收到了 1 个包含任期编号为 4 的投票请求(来 自节点 B)。那么节点 C 将会把唯一一张选票投给节点 A,当再收到节点 B 的投票请求 RPC 消息时,对于编号为 4 的任期,已没有选票可投了。
节点间如何通讯
在 Raft 算法中,服务器节点间的沟通联络采用的是远程过程调用(RPC),在领导者选举 中,需要用到这样两类的 RPC:
- 请求投票(RequestVote)RPC,是由候选人在选举期间发起,通知各节点进行投票;
- 日志复制(AppendEntries)RPC,是由领导者发起,用来复制日志和提供心跳消息。
其实在选举中,除了选举规则外,我们还需要避免一些会导致选举失败的情况,比如同一任 期内,多个候选人同时发起选举,导致选票被瓜分,选举失败。那么在 Raft 算法中,如何 避免这个问题呢?答案就是随机超时时间。
Raft 算法和兰伯特的 Multi-Paxos 不同之处,主要有 2 点
- 首先,在 Raft 中,不是所有节点都能当选领导者,只有日志完整的节点,才能当选领导者; 其次,在 Raft 中, 日志必须是连续的。
- Raft 算法通过任期、领导者心跳消息、随机选举超时时间、先来先服务的投票原则、大 多数选票原则等,保证了一个任期只有一位领导,也极大地减少了选举失败的情况。
如何复制日志?
在 Raft 算法中,副本数据是以日志的形式存在的,领导者接收到来自客户端写请求后,处理写请求的过程就是一个复制和提交日志项的过程。
日志是由日志项组成,日志项究竟是什么样子呢?其实,日志项是一种数据格式,它主要包含用户指定的数据,也就是指令(Command),
还包含一些附加信息,比如索引值(Log index)、任期编号(Term)。
你可以把 Raft 的日志复制理解成一个优化后的二阶段提交(将二阶段优化成了一阶段), 减少了一半的往返消息,也就是降低了一半的消息延迟。那日志复制的具体过程是什么呢?
- 首先,领导者进入第一阶段,通过日志复制(AppendEntries)RPC 消息,将日志项复制到集群其他节点上。
- 如果领导者接收到大多数的“复制成功”响应后,它将日志项提交到它的状态机,并 返回成功给客户端。如果领导者没有接收到大多数的“复制成功”响应,那么就返回错误给 客户端。
- 学到这里,有同学可能有这样的疑问了,领导者将日志项提交到它的状态机,怎么没通知跟 随者提交日志项呢?
这是 Raft 中的一个优化,领导者不直接发送消息通知其他节点提交指定日志项。因为领导 者的日志复制 RPC 消息或心跳消息,包含了当前最大的,将会被提交的日志项索引值。所 以通过日志复制 RPC 消息或心跳消息,跟随者就可以知道领导者的日志提交位置信息。 - 因此,当其他节点接受领导者的心跳消息,或者新的日志复制 RPC 消息后,就会将这条日志项提交到它的状态机。而这个优化,降低了处理客户端请求的延迟,将二阶段提交优化为了一段提交,降低了一半的消息延迟。
如何实现日志的一致
- 首先,领导者通过日志复制 RPC 的一致性检查,找到跟随者节点上,与自己相同日志项 的最大索引值。也就是说,这个索引值之前的日志,领导者和跟随者是一致的,之后的 日志是不一致的了。
- 然后,领导者强制跟随者更新覆盖的不一致日志项,实现日志的一致
如何解决成员变更的问题
在日常工作中,集群中的服务器数量是会发生变化的。Raft 是共识算法,对集群成员进行变更时(比如增加 2 台服务器),会不会因为集群分裂,出现 2 个领导者呢?”
关于成员变更,不仅是 Raft 算法中比较难理解的一部分,非常重要,也是 Raft 算法中唯一被优化和改进的部分。比如,初实现成员变更的是联合共识(Joint Consensus), 但这个方法实现起来难,后来 Raft 的作者就提出了一种改进后的方法,单节点变更 (single-server changes)。
Gossip协议:流言蜚语,原来也可以实现一致性
Gossip 的三板斧分别是:直接邮寄(Direct Mail)、反熵(Anti-entropy)和谣言传播 (Rumor mongering)。
ZAB协议
如何实现操作的顺序性?
兰伯特的 Multi-Paxos,虽然能保证达成共识后的值不再改变,但它不管关 心达成共识的值是什么,也无法保证各值(也就是操作)的顺序性。
- 首先,ZAB 实现了主备模式,也就是所有的数据都以主节点为准。
- ZAB 实现了 FIFO 队列,保证消息处理的顺序性。
- 另外,ZAB 还实现了当主节点崩溃后,只有日志最完备的节点才能当选主节点,因为日志最完备的节点包含了所有已经提交的日志,所以这样就能保证提交的日志不会再改变。
PoW算法
区块链通过工作量证明(Proof of Work)增加了坏人作恶的成本,以此防止坏人作恶。比如,如果坏人要发起 51% 攻击,需要控制现网 51% 的算力,成本是非常高昂的。为啥呢?因为根据 Cryptoslate 估算,对比特币进行 51% 算力攻击需要上百亿人民币!
区块链是通过执行哈希运算,然后通过运算后的结果值,证明自己做过了相关工作。区块链也是通过 SHA256 来执行哈希运算的,通过计算出符合指定条件的哈希值,来证明工作量的。因为在区块链中,PoW 算法是基于区块链中的区块信息,进行哈希运算的。
拜占庭容错算法(比如 PoW 算法、PBFT 算法),能容忍一定比例的作恶行为,所以它在相对开放的场景中应用广泛,比如公链、联盟链。非拜占庭容错算法 (比如 Raft)无法对作恶行为进行容错,主要用于封闭、绝对可信的场景中,比如私链、 公司内网的 DevOps 环境。我希望你能准确理解 2 类算法之间的差异,根据场景特点,选择合适的算法,保障业务高效、稳定的运行。