notes

======================= JUC并发编程 =======================

6种线程状态 (Thread类里的内部枚举类State):

线程间的通信方式:

  1. volatile、synchronized、lock (都保证可见性)
  2. wait、notify、await、signal
  3. 管道输入、输出流。传输的媒介为内存
  4. Thread.join (隐式唤醒。等待其他线程执行完成,其他线程会发送唤醒信号)
  5. ThreadLocal ---> 支持子线程继承的一种形式
  6. 线程中断
  7. Exchanger

轻量级锁 ---> 重量级锁:释放锁 (前4步)并唤醒等待线程

  1. 线程1初始化monitor对象;
  2. 将状态设置为膨胀中 (inflating);
  3. monitor里的header属性,设置成对象的mark word (将自己lock record里存放的mark word的hashcode, 分代年龄, 是否为偏向锁设置到objectmonitor对象的header属性中)
  4. 设置对象头为重量级锁状态 (标志位改为10),然后将前30位指向第1步初始化的monitor对象 (真正的锁升级是由线程1操控的)
  5. 唤醒线程2
  6. 线程2开始争抢重量级锁 (线程2就干了一件事,就是弄了一个临时的重量级锁指针吧?还不是最后的重量级指针。因为最后的重量级指针是线程1初始化的并且是线程1修改的。而且,线程2被唤醒之后,还不一定能抢到这个重量级锁。Sync是非公平锁。线程2 费力不讨好,但是线程2 做了一件伟大的事,它是锁升级的奠基者)

真正的锁升级,是依赖于 class的,而并不是依赖于 某一个new出来的对象 (偏向锁升级为轻量级锁)

真正的锁升级,是依赖于 当前new出来的对象的 (轻量级锁升级为重量级锁)

轻量级锁升级为重量级锁:这个时候,只要我们的线程发生了竞争,并且CAS替换失败,就会发起锁膨胀,升级为重量级锁 (针对的是一个对象实例)

synchronized锁升级 - Markword的转化过程

  • 创建一个对象,此时对象里边没有hashcode,所以该对象可以使用我们的偏向锁,偏向锁不会考虑hashcode,它会直接将自己线程id放到markword里边,不需要考虑后续的替换问题。所以,一旦对象主动调用了Object的hashcode方法,偏向锁就自动不可用了
  • 如果对象有了hashcode和分代年龄和是否为偏向锁 (共30位)。在轻量级锁的状态下,这30位会被复制到轻量级锁线程持有者的栈帧里的lock record里边记录。同时,对象的markword里存放的是指向轻量级锁线程持有者的栈帧的lock record里。如果一直存在轻量级锁竞争,在未发生锁膨胀的前提下,一直会保持轻量级锁,A线程释放的时候,会将markword替换回对象的markword里边,B线程下次再重新走一遍displace mark word
  • 一旦发生了轻量级锁膨胀为重量级锁 (前提,A线程持有锁;B线程争抢)。B线程将markword里A线程的指针替换成一个临时 (过渡的)重量级指针,为了让A线程在CAS往回替换markword的时候失败。A线程替换回markword失败后,会发起:1. 初始化monitor对象;2. 将状态设置为膨胀中;3. 将替换失败的markword放到objectmonitorheader属性里; 4. 改变markword的锁标志位为10;将markword里的30位设置为指向自己第一步初始化的那个monitor对象;5. 唤醒B线程;6. 以后这个对象只能作为重量级锁
  • markword从未丢失过

避免死锁的几个常见方法:

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
  • 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制
  • 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况

ObjectMonitor的属性

  1. header: 重量级锁保存markword的地方
  2. own: 指向持有锁的线程;对象的markword里也保存了指向monitor的指针
  3. _cxq队列: 竞争队列。A线程持有锁没有释放;B和C线程同时过来争抢锁,都被block了,此时会将B和C线程加入到该队列
  4. EntryList队列: 同步队列。A线程释放锁,B和C线程中会选定一个继承者 (可以去争抢锁的这个线程),另外一个线程会被放入EntryList队列里
  5. waitset: 等待队列。Object wait的线程

A线程持有锁,BC线程过来竞争失败,进入cxq --- 下轮竞争会把cxq里的线程移动到EntryList中。假设B线程竞争到了锁,然后B线程调用了Object.wait方法,这时候B线程进入waitset,并释放锁。C线程拿到了锁,然后唤醒B线程。B线程会从waitset里边出来,直接竞争锁。如果竞争失败进入cxq,继续轮回;如果竞争成功,ok了

volatile实现原理 - 可见性

  1. Lock前缀指令会引起处理器缓存回写到内存。在最近的处理器里,LOCK#信号一般不锁总线;而是锁缓存,毕竟锁总线的开销比较大,并使用缓存一致性协议机制来确保修改的原子性,此操作被称为"缓存锁定"
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32处理器和Intel 64处理器使用MESI (修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它们的内部缓存、系统缓存和其他处理器的缓存的数据在总线上保持一致

volatile实现原理 - 禁止指令重排序

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

为什么volatile读写和普通读写之间要插入内存屏障?

答:JSR-133增强volatile的内存语义。在JSR-133之前旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧Java内存模型允许volatile变量与普通变量重排序,这样会产生问题。

AQS – 同步队列:

  • AQS - 同步队列:
  • 同步队列AQS为什么在设置尾结点的时候需要使用CAS?

三个线程ABC, A持有锁, BC竞争失败, 需要添加到AQS同步队列的尾端。此时BC同时竞争tail节点, 这个时候就是要保证线程安全性, 正确地添加节点, 需要使用CAS操作

  • 同步队列设置头结点, 需要使用CAS吗?

不需要, 因为设置头结点的线程是已经获取锁成功的线程, 这个时候只有一条线程获取锁成功了,所以普通setHead节点就可以了。没有竞争, 就无需保证安全性。

  • AQS - 等待队列:
  • 等待队列也是一个FIFO的队列。同步队列和等待队列中节点类型都是同步器的静态内部类AbstractQueuedSynchronizer.Node (NodeStatus CONDITION = -2; SIGNAL=-1; CACELLED=1)
  • 节点引用更新的过程并没有使用CAS保证 (因为我们调用await方法的线程当前是持有锁的)
  • 同步队列和等待队列之间的转换过程

一个线程持有锁, 调用了await方法之后加入了等待队列进行排队, 当这个线程被唤醒 (需要执行await后边的代码), 需要重新竞争锁 (因为await方法将锁释放掉了)。如果竞争成功还行; 如果竞争失败, 就会加入到同步队列里进行排队。如果排到了同步队列的头部且争抢锁成功, 就继续执行await方法后边的代码。如果在执行过程中又调用了await方法, 就再次回到等待队列, 依次循环下去。

Condition - await: 释放锁

Condition - signal: 唤醒等待队列的第一个节点

// CountDownLatch针对业务完成之后放行 (赛跑过程里边的到达终点)

// CyclicBarrier针对的是所有线程在统一的屏障集合之后开始 (赛跑过程里边的起跑线)

// CyclicBarrier能够支持一个Runnable的action去做后续的数据操作。能够适用于更       加复杂的场景

SynchronousQueue:

1. 不存储数据的队列, 阻塞队列。使用场景, 适合短期的小并发场景, 且处理数据相当快速。

硬要说点好处:首先它没有缓冲容量, 那么它可以避免在服务器宕机的情况下, 从queue的角度来说, 没有数据丢失这么一说。它类似于一个传球手, 中间没有任何介质阻碍。如果单纯地进行数据的传递且生产的线程与消费的线程生产时间和消费时间都比较匹配的话, 它的性能能够很高。

2. cachedThreadPool里使用的就是SynchronousQueue, cachedThreadPool的使用场景就是处理快速短期的小并发场景。cachedThreadPool没有核心线程数, 完全依赖最大线程数, 直接依赖操作系统创建线程, 如果是短期的小并发, 在线程达到keepAliveTime以后, 可以自行销毁。

另外一种发问方式就是:线程池为什么要有一个核心线程数和最大线程数的区分呢?

1. 核心线程数和最大线程数中间还有一个queue呢

2. 如果仅仅依靠核心线程数,  比如我们将核心线程数的值设置得非常大, 每次有新任务过来, 都有可能在核心线程数里边创建新的线程, 可能会造成全局锁的获取, 导致性能瓶颈

继续发问:全局锁?在哪有这个逻辑?

答:当ThreadPoolExecutor进行execute方法执行的时候, 如果当前的工作线程小于corePoolSize, 就会进行新的工作线程的添加, 调用addWorker方法, 这个方法里, 当进行最终的workers.add的时候, 是在一个ReentrantLock里执行的, 也就是说, 此处不允许并发添加新的worker, 如果同时有多个线程进来, 且都小于corePoolSize, 只能排队添加。

线程池的7个参数:

  • corePoolSize (线程池的基本大小)
  • maximumPoolSize (线程池最大数量)
  • keepAliveTime (线程活动保持时间)
  • unit (线程活动保持时间的单位)
  • workQueue (任务队列)
  • threadFactory
  • handler (饱和策略):

- AbortPolicy: 相对多一些。因为我们会在异常处理的过程中进行各种手段, 如记录日志, 存入数据库等待retry。。。

- CallerRunsPolicy: 用的也相对少一些。调用者线程也是系统资源, 说明线程数量已经很多了, 调用者线程的加入其实是变相增加了maximumPoolSize

- DiscardOldestPolicy: 几乎没人使用

- DiscardPolicy: 这种使用的也少

当然, 也可以根据应用场景需要实现RejectedExecutionHandler接口自定义策略 (最推荐)

如何合理配置线程池?

要想合理地配置线程池, 就必须首先分析任务特性, 可以从以下几个角度来分析:

- 任务的性质:CPU密集型任务、IO密集型任务和混合型任务

- 任务的优先级:高、中和低

- 任务的执行时间:长、中和短

- 任务的依赖性:是否依赖其它系统资源,如数据库连接 (如线程池是20 corePoolSize, 但是数据库同时只支持10个连接, 这个时候要选择不能够大于数据库的连接数的一个数字, 最大选10, maximumPoolSize也是选10)

- 性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务应配置尽可能小的线程, 如配置N+1线程的线程池。由于IO密集型任务线程并不是一直在执行任务, 则应配置尽可能多的线程, 如2N。

- 建议使用有界队列, 有界队列能增加系统的稳定性和预警能力

1. 线程池是生存在一个复杂的系统环境中, 我们还有其他的接口需要使用我们的服务器资源, 所以在进行线程池corePoolSize和maximumPoolSize配置的时候, 我们需要明确当前接口的重要性。如果当前接口占据了未来业务访问的50%, 那么就可以分配50%的系统资源给当前接口 (一个服务, 总有一些重要接口和非重要接口, 在进行项目开发初期, 需求就定好了)

2. 一定要基于压测, 来评估线程池的参数是否合理 (全服务压测)

3. 我们要给线程池开后门, 可以动态地调整线程池的参数 (现在很多大型项目都有自己的配置中心。Apollo是一个非常好的配置组件, 你可以将corePoolSize和maximumPoolSize配置到配置中心, 一旦发生不可控的高并发场景, 可以随时修改配置中心的参数, 我们的项目就会按照新的标准进行调整)

  • FixedThreadPool和SingleThreadPool容易造成queue的消息无限积压, 这会导致无法触发拒绝策略。所以一般没人用;
  • CachedThreadPool可能高并发下, 无法控制最高的线程创建数量, 造成cpu和内存资源的消耗 (消耗完)。这种破线程池谁用?

这三个, 仅仅是为了让我们创建线程池的时候方便一些, 不代表它们很实用

======================== Spring源码 ========================

@Autowired & @Resource:

- @Autowired是Spring提供的注解, @Resource是JDK提供的注解

- @Autowired默认的注入方式是byType (根据类型进行匹配), @Resource默认注入方式为byName (根据名称进行匹配)

- 当一个接口存在多个实现类的情况下, @Autowired和@Resource都需要通过名称才能正确匹配到对应的bean, @Autowired可以通过@Qualifier注解来显式指定名称, @Resource可以通过name属性来显式指定名称

- @Autowired支持在构造函数、方法、字段和参数上使用。@Resource主要用于字段和方法上的注入, 不支持在构造函数或参数上使用。

Spring三级缓存:

- Spring中的生命周期是: 实例化 ---> 属性填充 (进行循环依赖的属性填充) ---> 初始化 ---> aop

- aop每次可能生成不同的代理对象, 可是由于aop的位置太靠后了, 没办法干预到属性填充, 就导致, 属性填充过程中, 没办法使用到aop生成的新的代理类, 导致依赖注入的错误

- 所以, 我们需要引入一个三级缓存, 这个三级缓存, 需要有延迟加载的功能 (钩子, ObjectFactory函数式编程, 回调方法)

Spring解决循环依赖的方式:

1. 构造器 (构造函数依赖注入) 不能解决

2. 多例的 (prototype原型的) 不能解决 (因为多例bean无法进行缓存, 也就谈不上三级缓存, 也就谈不上解决)

3. setter注入。能够解决。解决方式-三级缓存。

如果存在这种依赖关系的对象, 它会提前暴露一个factory工厂的入口 (a和相互依赖, 初始化a发现没有b, 初始化b发现没有a。提前将a和b通过factorybean的形式进行一个暴露, 此时的暴露并没有真正地进行对象的创建, 延迟创建, 必须等到调用ObjectFactory的getObject方法的时候, 才会进行对象的创建)。对于提前暴露来说, 它能够使互相有依赖的对象找到一个引用, a发现没有b的实例, 但是有b暴露的factory接口, 那么对于a来说, 就相当于找到了b, 即便此时b没有真正地初始化。但是通过ObjectFactory的getObject方法的调用, 最终完成b的实例化, 最终将a的依赖属性补充完整。

Spring中bean的类型:

1. 普通的bean, 通过@Component, @Service这种注解进行bean的注册

2. factorybean, 它需要通过getObject方法的回调, 进行对象的创建, 而且如果我们想要获取factorybean的话, 在使用getBean方法的时候, 需要在beanName前加一个&, getBean(“&beanName”)

aop的功能, 是在什么时候被执行的?

答:BeanPostProcessors中进行aop的实现

JDK的动态代理和cglib动态代理有什么区别 (Spring中createProxy方法中自动选择; 也可以通过设置optimize参数手动选择)?

- JDK动态代理是不需要使用第三方库的, 只需使用 jdk自带 就能够实现

  • 实现InvokeHandler接口, 重写invoke方法; 通过 反射 进行的代理
  • 使用了proxy.newProxyInstance进行创建 (生成代理对象)
  • JDK代理只针对 接口 (interface)
  • JDK动态代理采用的是 委托 机制

- cglib动态代理, 需要使用 第三方库 (cglib库)

  • 实现MethodIntercepor接口, 重写intercept方法; 通过 字节码生成代理对象
  • cglib通过enhancer的create方法进行创建
  • cglib代理针对的是 子类实现类
  • cglib动态代理采用的是 继承 机制

Spring中常见的设计模式? ---> refresh方法为主线回答!!!

1. 桥接模式工厂模式的配合:

对于ApplicationContext和BeanFactory来说, 如果想要使用getBean方法, 我们会通过ApplicationContext调用吧? 为什么getBean方法能够通过ApplicationContext调用, 因为ApplicationContext通过构造函数的形式把BeanFactory进行属性的设置, 这是桥接模式的典型应用。桥接模式能够把实现和抽象分离, 实现部分是属于BeanFactory, 真正的实现逻辑在BeanFactory; 而抽象部分属于ApplicationContext, 抽象部分暴露给调用端。所以getBean的暴露, 是通过ApplicationContext进行的。当然了, 这里边还是使用了工厂设计模式, 因为getBean方法的实现是在BeanFactory中实行的 (getBeanFactory().getBean(name, requiredType);)

2. 享元模式误区, 并非单例模式

getBean的单例模式的对象管理; 单例工厂。 我们想象中的单例模式是怎么做的 (饿汉式+懒汉式+双重检查锁)?

“Spring是怎么做的呢? 难道Spring真的使用了单例模式吗?”

Spring没有使用单例模式, 因为Spring是针对的bean进行的缓存, 每个singleton的scopebean只进行一次创建, 存放到了一个Map里。请问我们所说的单例模式, 有Map这个缓存吗? 所以Spring谈不上使用了单例模式, 它只是借助了单例模式的思想。说白了Spring无非就是提前创建了一些存储bean的缓存, 无需多次创建。Spring引申的这种bean管理的模式, 更类似于享元模式。”

3. 模板模式

摆在眼前的refresh方法 (可以聊BeanPostProcessors、onRefresh方法等)。

引申:

1. BeanPostProcessor (里面的postProcessBeforeInitializationpostProcessAfterInitialization方法)

2. postProcessAfterInitialization, aop的入口 !!!

3. onRefresh方法对springboot的贡献

4. 代理模式

aop这部分源码 (BeanPostProcessor)

5. 观察者 (监听器) 模式

refresh方法中有initApplicationEventMulticaster (发布工具), 以及registerListener (监听者)方法

6. 责任链模式

filter和interceptor (xml配置文件中进行filter的添加, interceptor的添加, 并且可以对自定义的进行顺序的把控)

7. 策略模式

Resource类。我们常用的是classpath的resource的加载, 如果我们有远程的或者本地服务器的文件类型, inputstream类型, 其实也都是可以加载的, 根据不同的加载类型, 使用不同的加载逻辑

=========================== Redis ===========================

===== Redis底层数据结构 =====

String - SDS:

- buf属性最后一个字节则保存了空字符’\0’

String - SDS的好处?

1. C语言字符串如果想得到它的长度, 需要进行遍历, O(N); 长度直接从len属性获取, O(1)

2. 空间预分配惰性空间释放 (惰性空间删除)

如果存储的是一个超过1M的字符串, free只分配1M

3. 二进制安全问题。对于C语言来说, 它的字符串是二进制不安全的, 因为C语言的 空字符 结尾的设计, 如果一个字符串中间有空字符, 那么C语言的字符串的二进制转化会遗弃第一个空字符出现的后边的所有内容; SDS是封装的对象, 能支持二进制的安全性, 能全部进行转化。

4. C语言空字符串结尾有二进制问题, 为什么SDS的buf属性里还是以空字符结尾呢? 为了兼容一部分C语言中字符串的操作

List: 就是列表 (源码是list: listNode的关系。它的底层实现链表, 源码是listNode: prev、next、value)

SDS在里边干啥的?

第一个SDS: 给list准备的, free = 4; len = 4; buf = [list…\0]; 这是列表的名称, 列表的key;

第二个SDS是给a准备的, 依次类推, 第四个SDS是给c准备的

SDS的a b c这三个对象, 保存到哪里了?

保存到了listNode源码里边的void * value属性里了

Hash (字典, 由dict.h/dict结构表示): Redis的字典使用哈希表 (dict.h/dictht)作为底层实现, 一个哈希表 (dictht)里面可以有多个哈希节点 (dictEntry), 而每个哈希表节点 (dictEntry)就保存了字典中的一个键 (void * key) 值 (union{void * val; unit64_tu64; unit64_ts64;} v)对, dictEntry中的dictEntry *next指向下个哈希表节点, 形成链表 (hash冲突, 链地址法)

ht属性是一个包含两个哈希表的数组, 数组中的每个项都是一个dictht哈希表。一般情况下, 字典只使用ht[0]哈希表, ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用

rehash:

- 过程: 释放ht[0], 将ht[1]设置为ht[0], 并在ht[1]新创建一个空白哈希表, 为下一次rehash做准备

         - 触发时机:

  • 扩展:

1.服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令, 并且哈希表的负载因子大于等于1

2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令, 并且哈希表的负载因子大于等于5

  • 收缩: 当哈希表的负载因子小于0.1时, 程序开始自动对哈希表执行收缩操作

渐进式rehash:

         - 分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]

- 在字典中维持一个索引计数器变量rehashidx, 并将它设置为0, 表示rehash工作正式开始 ---> 每迁移一个key value, 就需要在rehashidx上自增1. ---> ht[0]的所有键值对都被rehash到ht[1], rehashidx属性的值被设为-1, 表示rehash操作已完成 (包括之前所说的, ht[1]成为了ht[0], 再重新创建一个空的ht[1]备用)

跳跃表: 作为有序集合键的底层实现之一

- Redis只在两个地方用到了跳跃表, 一个是实现有序集合键, 另一个是在集群节点中用作内部数据结构

- 随机生成一个介于132之间的值作为level数组的大小, 这个大小就是层的"高度"

压缩列表: 压缩列表 (ziplist)是列表和哈希的底层实现之一

- 压缩列表中的entry节点中的encoding属性: 最高位为00/01/10开头的是字节数组编码, 11开头的是整数编码

- entry节点中的previous_entry_length属性: 压缩列表的从表尾向表头遍历操作就是基于这个属性实现的

(极端情况, 连锁更新, 即压缩链表中存在多个连续的长度介于250字节到253字节之间的节点)

整数集合 (intset) : 是集合键 (就是代表集合)的底层实现之一 ---> 除此之外, 还有hashtable

(编码升级)

===== Redis5种对象 (数据)类型 =====

Redis中的每个对象都由一个redisObject结构表示。其中, 对象的ptr指针指向对象的底层实现数据结构, 而这些数据结构由对象的encoding属性决定:

typedef struct redisObject {

unsigned type:4;

unsigned encoding:4;

void *ptr;

} robj;

1. 字符串对象:

2. 列表对象:

列表对象的编码可以是ziplist或者linkedlist。当列表对象可以同时满足以下两个条件时, 列表对象使用ziplist编码:

- 列表对象保存的所有字符串元素的长度都小于64字节

- 列表对象保存的元素数小于512个

3. 哈希对象:

哈希对象的编码可以是ziplist或者hashtable。当哈希对象可以同时满足以下两个条件时, 哈希对象使用ziplist编码:

… …

(ziplist编码: 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾; 保存了同一键值对的两个节点总是紧挨在一起)

4. 集合对象:

集合对象的编码可以是intset或者hashtable。当集合对象可以同时满足以下两个条件时, 对象使用intset编码:

- 集合对象保存的所有元素都是整数值

- 集合对象保存的元素数量不超过512个

5. 有序集合对象:

有序集合对象的编码可以是ziplist或者skiplist。当有序集合对象可以同时满足以下两个条件时, 对象使用ziplist编码:

- 有序集合保存的元素数量小于128个

- 有序集合保存的所有元素成员的长度都小于64字节

之所以元素数量限制在128, 是为了让有序集合尽快地使用skiplist, 因为skiplist性能是平均二分查找的

对象空转时长 (次重点):

redisObject中的unsigned lru:22属性。如果服务器打开了maxmemory选项, 并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru, 那么当服务器占用的内存超过了maxmemory选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放。

Redis持久化

- RDB持久化

  • 写入流程:BGSAVE命令会派生出一个子进程, 然后由子进程负责创建RDB文件, 服务器进程 (父进程)继续处理命令请求 (redis的主从复制的时候)
  • BGSAVE参数配置实现原理:

save 900 1

save 300 10

save 60 10000

服务器会根据save选项所设置的保存条件, 通过设置服务器状态redisServer结构的saveparams属性:

struct redisServer {

struct saveparam *saveparams;

// 修改计数器

long long dirty;

// 上一次执行保存的时间

time_t lastsave;

}

struct saveparam {

time_t seconds;

int changes;

}

Redis服务器的周期性操作函数serverCron默认每隔100毫秒就会执行一次, 该函数用于对正在运行的服务器进行维护。它的一项工作就是检查save选项所设置的保存条件是否已经满足, 如果满足的话, 就执行BGSAVE命令。

  • RDB文件结构 (重点):

带有过期时间的键值对在RDB文件中的结构如图:

- AOF持久化

  • 写入流程:1. 客户端发起命令操作 (查询操作除外); 2. 服务端接收命令并将命令进行执行, 与此同时, 将命令写入redisServer类中aof缓冲区; 3. 通过appendfsync参数的配置进行缓冲区同步到aof磁盘文件的操作
  • 读取流程:1. 创建一个不带网络连接的伪客户端 (fake client); 2. 从AOF文件中分析并读取出一条写命令; 3. 使用伪客户端执行被读出的写命令; 4. 一直重复步骤2和3, 直到AOF文件中的所有写命令都被处理完毕为止
  • 命令追加实现原理:当AOF持久化功能处于打开状态时, 服务器在执行完一个写命令后, 会以协议格式将被执行的写命令追加到redisServer服务器状态结构的aof_buf缓冲区属性的末尾:

struct redisServer {

// AOF缓冲区

sds aof_buf;

}

- AOF重写

  • 设置了一个AOF重写缓冲区
  • AOF重写过程:子进程先写已有的, 父进程写新加入的, 父进程完成最终的AOF文件替换

Redis Client:

typedef struct redisClient {

sds querybuf; // 客户端状态的输入缓冲区用于保存客户端发送的命令请求

robj **argv; // argv属性是一个数组, set msg "hello"会被保存为三个数组元素

int argc; // 表示命令长度, set msg "hello", argc3

char buf[REDIS_REPLY_CHUNK_BYTES]; // 执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里

int bufpos; // 记录了buf数组目前已使用的字节数量

time_t_obuf_soft_limit_reached_time; // 记录了输出缓冲区第一次到达软性限制 (soft limit)的时间

}

redisServer - serverCron函数:

4. 更新服务器内存峰值

struct redisServer {

/** 每次serverCron函数执行时, 程序都会查看服务器当前使用的内存数量, 并与stat_peak_memory保存的数值进行比较, 如果当前使用的内存数量比stat_peak_memory属性记录的值要大, 那么程序就将当前使用的内存数量记录到stat_peak_memory属性里面 */

size_t_stat_peak_memory;

}

复制 (主从)

Redis 2.8之前是SYNC, 2.8之后是PSYNC (区别:SYNC不支持部分重同步)。

Redis的复制功能分为同步 (sync)命令传播 (command propagate)两个操作:

1. 同步。

  • 是第一次进行主从服务器连接的时候, 一定会进行同步操作 (主服务器--RDB文件传输--从服务器)
  • 从服务器断线期间, 如果新的命令都存在主服务器的缓冲区里, 那么就直接发送这部分命令 (命令传播)给从服务器就可以了; 如果这些新的命令, 不全部存在于主服务器的缓冲区中, 则触发同步操作 (RDB文件)

2. 命令传播。

  • 第一次连接完主服务器后, 通过RDB文件同步之后的所有的命令, 都是采用命令传播的形式
  • 同理, 断线重连之后的所有主从数据的同步都采用命令传播

心跳检测:

在命令传播阶段, 从服务器会以每秒一次的频率, 向主服务器发送命令:

REPLCONF ACK <replication_offset>, 其中replication_offset是从服务器当前的复制偏移量。

哨兵

Sentinel是一个类似于Redis服务器的服务器

- 哨兵介绍:

  • 如果主服务器进行了断开, 替换了从服务器, 除了被提升为主服务器的从服务器, 所有的从服务器都必须做一次完整重同步 (服务器的运行ID发生了变化, 进行完整重同步), 原来的主服务器上线之后, 无论作为谁的从服务器, 都必须进行一次完整重同步, 提升为新的主服务器的压力在于BGSAVE (几乎进行一次即可), 但是要进行多份的网络传递; 第二次BGSAVE应该发生在"新的"从服务器连接到主服务器。---> 主服务器断连, 是一件很可怕的事情, 会造成服务短时间不可用 (还没选举出新的主服务器的时候), 再次造成服务的卡顿 (BGSAVE和RDB文件传输的时候)
  • 集群, 多个主服务器, 这个主服务器只是一个集群里的节点, 如果这个节点挂了, 那集群会将请求路由到其他的主服务器节点上。上面说的压力, 在集群环境下就微乎其微了。
  • 如果是从服务器下线了, 就是之前所说的断线重连psync那部分内容。

- 获取主服务器信息 (Sentinel默认每十秒一次, 向被监视的主服务器发送INFO命令):

  • 在启动并初始化Sentinel的时候为什么不直接把主从服务器信息一起搞下呢?

答: 在代码里, 只保存了dict master的属性, 并且通过master我们可以很容易地拿到slaves, 所以简便起见, 第一步初始化时, 只进行master的连接, 当确认master ok的时候才进行从服务器的信息收集。

- 向主服务器和从服务器发送信息

  • 在默认情况下, Sentinel会以每两秒一次的频率, 通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令: PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"

- 检测主、客观下线状态

  • 在默认情况下, Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例 (包括主服务器、从服务器、其他Sentinel在内)发送PING命令
  • 主观下线: Sentinel配置文件中的down-after-milliseconds选项制定了Sentinel判断实例进入主观下线所需的时间长度
  • 客观下线: 当认为主服务器已经进入下线状态的Sentinel的数量, 超过Sentinel配置中设置的quorum参数的值, 那么该Sentinel就会认为主服务器已经进入客观下线状态

- 选举领头Sentinel及故障转移

  • 一旦发现主服务器下线了, 一定要选举出一个新的主服务器, 谁去选择这个新的主服务器呢? ---> 领头的Sentinel去选择新的主服务器, ---> 谁是领头的Sentinel呢? 选举算法

Redis Cluster

- 连接各个节点的工作可以使用CLUSTER MEET命令来完成: CLUSTER MEET <ip> <port>

- 槽与槽指派: key = CRC16(key) & 16383, 无非就是一个key值计算的结果, 只不过这个结果位于0 - 16383, 所以我们把它称之为16384个槽; 如集群中有三个节点7001,7002,7003, 槽指派就是: 7000节点处理0 - 5000的值, 7001处理5001 - 10000, 7002处理10001 – 16383的

- 槽指派细节:

  • clusterNode结构的slots属性和numslots属性记录了该节点负责处理哪些槽。

struct clusterNode {

      unsigned char slots[16384/8];

      int numslots;

}

slots属性是一个二进制位数组 (bit array), 这个数组的长度为16384/8=2048个字节, 共包含16384个二进制位。

如果slots数组在索引i上的二进制位的值为1, 那么表示该节点负责处理槽i; 如果slots数组在索引i上的二进制位的值为0, 那么表示该节点不负责处理槽i。

但依然需要有一个地方标注 整个集群节点的mapping到槽 的这个关系呢?

  • clusterState结构中的slots数组记录了集群中所有16384个槽的指派信息。

typedef struct clusterState {

      clusterNode *slots[16384];

} clusterState;

       ​​​​​​​​​​​​​​

  • 如果键所在的槽并没有指派给当前节点, 那么节点会向客户端返回一个MOVED重定向指令, 指引客户端转向 (redirect)至正确的节点, 并再次发送之前想要执行的指令

- 重新分片 (重新槽指派) : Redis集群的重新分片操作是由Redis集群管理软件redis-trib负责执行的

    

慢查询日志:

服务器配置有两个和慢查询日志相关的选项:

- slowlog-log-slower-than选项指定执行时间超过多少微秒(1秒等于1 000 000微秒)的命令请求会被记录到日志上。

- slowlog-max-len选项指定服务器最多保存多少条慢查询日志。

========================== MySQL ==========================

InnoDB体系架构:

- 缓冲池的内存管理方式:

  • 在InnoDB存储引擎中, 缓冲池中页的大小默认为16KB, 同样使用LRU算法对缓冲池进行管理。稍有不同的是InnoDB存储引擎对传统的LRU算法做了一些优化。在InnoDB的存储引擎中, LRU列表中还加入了midpoint位置。新读取到的页, 虽然是最新访问的页, 但并不是直接放入到LRU列表的首部, 而是放入到LRU列表的midpoint位置。这个算法在InnoDB存储引擎下称为midpoint insertion strategy。在默认配置下, 该位置在LRU列表长度的5/8

midpoint位置可由参数innodb_old_blocks_pct控制。

  • 参数是innodb_old_blocks_time, 用于表示页读取到mid位置后需要等待多久才会被加入到LRU列表的热端。

当页从LRU列表的old部分加入到new部分时, 称此时发生的操作为page made young; 而因为innodb_old_blocks_time的设置而导致页没有从old部分移动到new部分的操作称为page not made young

InnoDB关键特性:

- 插入缓冲 (insert buffer):

  • 对于非聚集 (辅助)索引, InnoDB存储引擎开创性地设计了Insert Buffer。Insert Buffer的数据结构是一棵B+树

- 两次写 (doublewrite):

  • doublewrite由两部分组成, 一部分是内存中的doublewrite buffer, 大小为2MB; 另一部分是物理磁盘上共享表空间中连续的128个页, 即2个区 (extent), 大小同样为2MB。
  • doublewrite不是为了速度, 而是为了数据的可靠性

慢查询日志:

  • long_query_time, 记录运行时间超过该值的所有sql语句
  • long_queries_not_using_indexes, 记录没有使用索引的sql语句
  • log_throttle_queries_not_using_indexes, 每分钟允许记录到slow log的且未使用索引的sql语句次数

InnoDB逻辑存储结构:

  • 所有数据都被逻辑地存放在一个空间中, 称之为表空间 (tablespace)。表空间又由段 (segment)、区 (extent)、页 (page)组成。页在一些文档中有时也称为块 (block)​​​​​​​
  • 如果启用了innodb_file_per_table, 需要注意的是

1. 每张表的表空间内存放的只是数据索引插入缓冲Bitmap

2. 其他类的数据, 如回滚 (undo)信息, 插入缓冲索引页、系统事务信息、二次写缓冲 (Double write buffer)等还是存放在原来的共享表空间内

行记录格式 (默认是Compact、Redundant已弃用):

CREATE TABLE mytest(

t1 VARCHAR(10),

t2 VARCHAR(10),

t3 CHAR(10),

t4 VARCHAR(10)

)ENGINE=INNODB CHARSET=LATIN1 ROW_FORMAT=COMPACT;

INSERT INTO mytest VALUES('a','bb','bb','ccc');

INSERT INTO mytest VALUES('d','ee','ee','fff');

INSERT INTO mytest VALUES('d',NULL,NULL,'fff');

61 /*1数据'a'*/

62 62 /*2数据'bb'*/

62 62 20 20 20 20 20 20 20 20 /*3数据'bb'*/

63 63 63 /*4数据'ccc'*/

CHAR & VARCHAR区别: CHAR在存储时会在右边填充空格以达到指定的长度, 在检索时会去掉空格

索引管理:

  • FIC (Fast Index Creation): 从InnoDB 1.0.x版本开始支持一种称为FIC的索引创建方式。对于辅助索引的创建, InnoDB存储引擎会对创建索引的表加上一个S。因此,

在创建的过程中只能对该表进行读操作, 若有大量的事务需要对目标表进行写操作, 那么数据库的服务同样不可用。

  • Online DDL (MySQL 5.6版本开始支持): 其允许辅助索引创建的同时, 还允许其他DML操作, 极大地提高了MySQL数据库在生产环境中的可用性

1. 辅助索引的创建与删除、改变自增长键值、添加或删除外键约束、列的重命名

2. 原理是 在执行创建或者删除操作的同时, INSERTUPDATEDELETE这类DML操作日志写入到一个缓存

ALTER TABLE tbl_name

|ADD{INDEX|KEY}[index_name]

[index_type](index_col_name,...)[index_option]...

ALGORITHM[=]{DEFAULT|INPLACE|COPY}

LOCK[=]{DEFAULT|NONE|SHARED|EXCLUSIVE}

索引优化:

  • MRR (Multi-Range Read)优化: 目的就是为了减少磁盘的随机访问, 并且将随机访问转化为较为顺序的数据访问
  • ICP (Index Condition Pushdown): 将WHERE的部分过滤操作放在了存储引擎层。在某些查询下, 可以大大减少上层SQL层对记录的索取 (fetch)

InnoDB存储引擎中的锁:

  • 行级锁:共享锁 (S Lock)、排他锁 (X Lock)

1. X锁与任何的锁都不兼容, 而S锁仅和S锁兼容

  • 表级锁:意向共享锁 (IS Lock)、意向排他锁 (IX Lock)

1. 主要是为了在一个事务中揭示下一行将被请求的锁类型。这种锁允许事务在行级上的锁和表级上的锁同时存在。

2. 由于InnoDB存储引擎支持的是行级别的锁, 因此意向锁其实不会阻塞除全表扫以外的任何请求

3. IS IS、IS IX、IX IS、IX IX不会有不兼容的情况

  • 一致性非锁定读 (快照读):之所以称其为非锁定读, 因为其不需要等待访问的行上X锁的释放

自增长与锁:

  • RC + RBR下, innodb_autoinc_lock_mode一般设置为2 (即对于所有”INSERT-like”(即所有的INSERT语句)自增长值的产生都是通过互斥量) ===> 然而, 因为并发插入的存在, 在每次插入时, 自增长的值可能不是连续的
  • 而RR + SBR下, innodb_autoinc_lock_mode一般设置为0 (即通过表锁的AUTO-INC Locking的方式)

事务隔离级别:

  • 脏读 (不可以接受):脏读指的就是在不同的事务下, 当前事务可以读到另外事务未提交的数据
  • 幻读:      同一事务执行两次相同的查询, 第一次查询的结果数量与第二次查询的结果数量不一致  ===> 和不可重复读非常类似。
  • 不可重复读:同一事务执行两次相同的查询, 第一次查询的结果的值与第二次查询的结果的值不一致
  • RR模式下使用的是next key locking; RC模式下使用的是Gap Lock (只包括外键约束和唯一性检查)

事务的四大特性

ACID。redo log用来保证事务的原子性和持久性; undo log用来保证事务的一致性; 锁用来保证事务的隔离性。

Group Commit:

- BLGC:在MySQL数据库上进行提交时首先按顺序将事务放入到一个队列中, 一次fsync可以刷新确保多个事务日志被写入文件中

MySQL调优

  • 单表调优。

1. 多插入的调优。(批量插入; id自增; group commit)

2. 多查询的调优。索引, explain, B+树 (覆盖索引特性多理解), 严格约束字段的长度, 能用数字就用数字, 尤其是tinyint。(男女, 0, 1) (like, join)

  • 分区, 更快的查询速度
  • 拆分表 (水平拆分、垂直拆分 = 减少单表的字段量)

  • 如果5.6+版本, 开启MRR优化、ICP优化

  • 测试 (非压测环境, 非生产环境)环境开启慢查询日志。进行日常、上线前的测试。分析慢查询sql
  • CPU/磁盘/内存/RAID

MySQL主从复制的几种方式

1. 异步复制

2. 半同步复制

3. 增强半同步复制

增强半同步和半同步不同的是, 等待ack时间不同。rpl_semi_sync_master_wait_point = AFTER_SYNC (唯一区别)

增强半同步将等待ack的点放在提交commit之前

分库分表后, 如何保证全局的唯一主键id呢?

  • UUID: 不适合作为主键, 因为太长了, 并且无序不可读, 查询效率低。比较适合用于生成唯一的名字的标识比如文件的名字。
  • 数据库自增id: 两台数据库分别设置不同步长, 生成不重复id的策略来实现高可用。这种方式生成的id有序, 但是需要独立部署数据库实例, 成本高, 还会有性能瓶颈。
  • 利用redis生成id: 性能比较好, 灵活方便, 不依赖于数据库。但是, 引入了新的组件造成系统更加复杂, 可用性降低, 编码更加复杂, 增加了系统成本。

- SELECT COUNT(*) FROM buy_log WHERE buy_date >= ‘2011-01-01’ AND buy_date < ‘2011-02-01’

buy_log(userid, buy_date)的联合索引, 这里值根据列b进行条件查询, 一般情况下,是不能进行该联合索引的, 但是这句SQL查询是统计操作, 并且可以利用到覆盖索引的信息, 因此优化器会选择该联合索引

从图中可以发现列possible_keys依然为NULL, 但是列keyuserid_2, 即表示(userid, buy_date)的联合索引。在列Extra同样可以发现Using index提示, 表示为覆盖索引

- 假设表:t_index。其中id为主键; c1c2组成了联合索引(c1, c2); 此外, c1还是一个单独索引。进行如下查询操作:

SELECT * FROM t_index WHERE c1 > 1 and c1 < 100000;

然而通过EXPLAIN命令, 用户会发现优化器并没有按照OrderID上的索引来查找数据

在最后的索引使用中, 优化器选择了PRIMARY id聚集索引, 也就是表扫描 (table scan), 而非c1辅助索引扫描 (index scan)

这是为什么呢?因为如果强制使用c1索引, 就会造成离散读。如果要求访问的数据量很小, 则优化器还是会选择辅助索引; 但是当访问的数据占整张表中数据的蛮大一部分时 (一般是20%左右), 优化器会选择通过聚集索引来查找数据。===>>> MRR优化!!

=========================== JVM ===========================

Class文件结构

  • 常量池计数器:这个容量计数是从1而不是0开始的

1. Object类没有父类, 它的父类索引指向哪里呢? 指向00 00 (指向常量池里的第0个常量, 第0个常量什么都没有, 这个第0个, 就是为了给所有无法指向的情况提供的一个空常量指向)

2. 匿名内部类。(类名称指向哪里? 指向00 00)

  • ​​​​​​​​​​​​​​常量池

1. JVM是如何确定类的符号引用的 (寻找到类的名称的) (索引类的)?

答:当JVM运行过程中, 如果需要加载额外的类, 那么首先会通过对当前类的一个引用, 去指向常量池中的class info类型, 然后由class info类型指向utf8 info类型, 找到类的全限定名。在解析的过程中, 将全限定名的符号引用转为直接引用 (找到了类, 就是找到了方法)。

2. CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值, 即u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名, 即使规则和字符都是合法的, 也会无法编译 (超出了常量池里的utf8 info里的u2的最大限度)

  • 字段表与方法表

1. 类变量与实例变量有什么区别?

答:类变量是一个static的变量; 实例变量是非静态的全局的变量

class Test {

   public static String name = ‘123’ // 类变量

   public String name = ‘123’ // 实例变量

}

类变量可以直接用类名.变量名进行调用; 实例变量必须通过new对象的形式进行调用

  • 属性表

1. 方法表下的属性表下的code属性。

之前我们认为方法里的代码, 应该是以二进制的形式被完全照搬转化的。比如说写了一个int a = 0; 直接转为二进制。但是我们现在了解了, 并不是直接转化的书写的原始代码, 而是通过code属性保存的jvm指令码。

2. 字段表下的属性表下的ConstantValue属性。

对于非static类型的变量 (也就是实例变量)的赋值是在实例构造器<init>方法中进行的; 而对于类变量, 则有两种方式可以选择:在类构造器<clinit>方法 (static)中或者使用ConstantValue属性 (final+static+基本类型或String类型)。

类加载过程

  • 初始化:

Java虚拟机必须保证一个类的<clinit>方法在多线程环境中被正确地加锁同步, 如果多个线程同时去初始化一个类, 那么只会有其中一个线程去执行这个类的<clinit>方法 (并发编程篇 – 双重检查锁 – 通过实例化类进行的双重检查锁实现, static变量, clinit方法, 同步)

类加载器

  • 复写findClass方法。首先我们自定义的类加载器加载SelfClassLoader这个文件, 如果没有自定义的类加载器, 这个文件是应该被系统类加载器加载的, 现在我们自定义了一个类加载器, jvm为了保证只有一个类能够被加载, 所以jvm中只有一份该类的信息, 对于进行自定义类加载器的书写的时候, 复写findClass永远不会破坏双亲委派, 因为双亲委派的代码逻辑存在于loadClass这个方法中;

对于我们这个例子来说, 这个类其实就是被系统类加载器加载的, 哪怕在代码中用我们自定义的类加载器进行再次加载, 也会在Class<?> c = findLoadedClass(name); 从系统类加载器里获取该类

  • 复写loadClass方法。现在我们自定义的类加载器复写了loadClass方法, 里面没有双亲委派的任何逻辑; 对于我们自定义的类加载器加载的类, 就会出现类名重复的问题;

我们不小心复写了loadClass方法, 破坏了双亲委派模型, 导致com.boot.jvm.SelfClassLoader被系统类加载器和自定义的类加载器进行了两次加载, 而且最要命的是, 系统类加载器标注的类名称空间是一份, 自定义的类加载器也标注了一份类名称空间; 由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性

  • 自定义类加载器的使用场景?

1. 大家都用过class文件的反编译工具, 发现class文件反编译为jvm文件, 可读性还是很不错的。有一些企业, 为了保障核心代码不被反编译器反编译, 要将class文件再进行一次文件加密, 加密后的class文件不能够被jvm直接加载, 此时需要自定义一个类加载器将该class文件进行解密之后, 再加载到jvm

2. 有时候, 需要通过一些网络的文件, zip文件, db存储的class二进制文件, 进行jvm的加载, 这个时候, 由于加载源不同, 加载文件类型不同, 会有不同的自定义的类加载器

垃圾收集算法发展时间线

  • 1960年, Lisp之父John McCarthy标记清除算法。缺点, 内存空间的碎片化问题。===>
  • 1969年, Fenichel标记复制算法半区复制”。将内存容量划分为大小相等的两块, 每次只使用其中的一块。===>
  • 1974年Edward Lueders标记整理算法。缺点, 移动对象的Stop The World。===>
  • 1989年Andrew Appel标记复制算法一种更优化的半区复制分代策略, 现在称为”Appel式回收”。把新生代分为较大的Eden空间和两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。

垃圾收集 - 安全点

  • 安全点位置的选取基本上是以”是否具有让程序长时间执行的特征”为标准进行选定的, “长时间执行”的最明显的特征就是指令序列的复用, 例如方法调用、循环跳转、异常跳转等都属于指令序列复用, 所以只有具有这些功能的指令才会产生安全点。

如何在垃圾收集发生时让所有线程都跑到最近的安全点, 然后停顿下来。有两种方案可供选择:抢先式中断和主动式中断

安全区域:典型的场景便是用户线程处于Sleep状态或者Blocked状态

并发的可达性分析 - 三色标记

  • 白色, 表示对象尚未被垃圾收集器访问过; 黑色, 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过; 灰色, 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
  • 三色标记在并发扫描时会产生对象消失问题, 由此分别产生了两种解决方案:增量更新和原始快照 (SATB)。它们都是通过写屏障来实现的。
  • 在HotSpot虚拟机中, 增量更新和原始快照这两种解决方案都有实际应用。譬如, CMS是基于增量更新来做并发标记的; G1则是用原始快照来实现的

垃圾收集器 - CMS的缺点

  • CMS收集器对处理器资源非常敏感。默认启动的回收线程数是(处理器核心数量+3)/4
  • CMS无法处理”浮动垃圾”。所以Serial Old作为CMS收集器发生失败时的后备预案, 在并发收集发生Concurrent Mode Failure时使用
  • 空间碎片

CMS & G1

  • G1:初始标记-并发标记-最终标记-筛选回收。G1除了并发标记外, 其余阶段也是要完全暂停用户线程 (STW)的; CMS初始标记和重新标记STW
  • 与CMS的”标记-清除”算法不同; G1从整体看是基于”标记-整理”算法实现的收集器, 但从局部 (两个Region之间)上看又是基于”标记-复制”算法实现
  • 在用户程序运行过程中, G1无论是为了垃圾收集产生的内存占用 (Footprint)还是程序运行时的额外执行负载 (Overload)都要比CMS要高

(就内存占用来说, 虽然G1CMS都使用卡表来处理跨代指针, G1的卡表实现更为复杂, 而且堆中每个Region, 无论扮演的是新生代还是老年代角色, 都必须有一份卡表, 这导致G1的记忆集 (和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间; 相比起来CMS的卡表就相对简单, 只有唯一一份, 而且只需要处理老年代到新生代的引用, 反过来则不需要, 由于新生代的对象具有朝生夕死的不稳定性, 引用变化频繁, 能省下这个区域的维护开销是很划算的)

空间分配担保:

1. 在发生Minor GC之前, 虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立, 那这一次Minor GC可以确保是安全的

2. 如果不成立, 则虚拟机会先查看-XX: HandlePromotionFailure参数的设置值是否允许担保失败 (Handle Promotion Failure)。

  • 如果允许; 那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于, 将尝试进行一次Minor GC, 尽管这次Minor GC是有风险的
  • 如果小于; 或者-XX: HandlePromotionFailure设置不允许冒险, 那这时就要改为进行一次Full GC

======================= 高频面试题 ========================

jdk1.7和jdk1.8的HashMap的改动?

不同

JDK 1.7

JDK 1.8

存储结构

数组 + 链表

数组 + 链表 + 红黑树

初始化方式

单独函数: inflateTable()

直接集成到了扩容函数resize()中

hash值计算方式

扰动处理 = 9次扰动 = 4次位运算 + 5次 ^ 运算

扰动处理 = 2次扰动 = 1次位运算 + 1次 ^ 运算

存放数据的规则

无冲突时, 数组;

冲突时, 链表

无冲突时, 数组;

冲突时,

链表长度 < 8, 单链表;

链表长度 > 8, 树化并存放红黑树

插入数据方式

头插法 (先将原位置的数据移到后1位, 再插入数据到该位置)

尾插法 (直接插入到链表

尾部/红黑树)

扩容后存储位置的计算方式

全部按照原来方法进行计算 ( 即hashCode >> 扰动函数 >> (h&length-1) )

按照扩容后的规律计算 (即扩容后的位置 =

原位置or原位置+旧容量)

jdk1.7和jdk1.8的ConcurrentHashMap的区别?

其实可以看出jdk1.8版本的ConcurrentHashMap的数据结构已经接近HashMap, 相对而言, ConcurrentHashMap只是增加了同步的操作来控制并发, 从jdk1.7版本的ReentrantLock+Segment+HashEntry, 到jdk1.8版本中synchronized+CAS+HashEntry (Node?) +红黑树。

1. 数据结构: 取消了Segment分段锁的数据结构, 取而代之的是数组+链表+红黑树的结构。

2. 保证线程安全机制: jdk1.7采用Segment分段锁机制实现线程安全, 其中Segment继承自ReentrantLock; jdk1.8采用CAS+synchronized保证线程安全。

3. 锁的粒度: 原来是对需要进行数据操作的Segment加锁; 现在调整为对每个数组元素加锁 (Node)。

4. 链表转换为红黑树: 定位节点的hash算法简化会带来弊端, hash冲突增加, 因此在链表节点数量大于8时, 会将链表转换为红黑树进行存储。

5. 查询时间复杂度: 从原来的遍历链表O(N), 变为遍历红黑树的O(logN)

LinkedHashMap:

总的来说, LinkedHashMap底层是数组+单向链表+双向链表。数组+单向链表就是HashMap的结构, 记录数据用; 双向链表, 存储插入顺序用。

用LinkedHashMap实现LRU:

考察点两个:

1. accessOrder控制的 访问顺序

2. removeEldest方法的复写使用

为什么ArrayList中, 有两个类似的静态的final的Object数组EMPTY_ELEMENTDATA和DEFAULTCAPACITY_EMPTY_ELEMENTDATA

如果是通过DEFAULTCAPACITY_EMPTY_ELEMENTDATA定义的空list, 首次添加元素的时候, 返回默认容量DEFAULT_CAPACITY=10; 如果不是走的DEFAULTCAPACITY_EMPTY_ELEMENTDATA, 那么第一次会返回1:

  • ArrayList list = new ArrayList(); // 默认构造, 未添加元素, 容量为0
  • ArrayList list = new ArrayList(); list.add(1); // 默认构造, 添加一个元素, 容量为10
  • ArrayList list = new ArrayList(0); list.add(1); // 初始化容量为0, 添加第一个元素后, 容量为1

ArrayList中的elementData用transient修饰, 序列化后数据会丢失吗?

答:不会。不对elementData序列化, 对elementData中的元素进行循环, 取出来单独进行序列化。

那为什么不直接序列化elementData呢?这样设计有什么好处吗?

答:因为这个对象绝大多数情况下会存在没有存储任何元素的容量空间, 这样将会有很大的空间浪费。

流量突增百万如何保障系统高可用?

- 事前预防

1. 预估系统瓶颈

1.1 梳理核心接口

1.1.1 按调用量梳理Top100

1.1.2 PM按业务重要等级梳理下c端可能起量的接口

1.1.3 输出终版的接口文档

1.2 评估核心接口最高的TPS

1.2.1 测试环境模拟生产环境数据

1.2.2 核心接口进行全链路压测

1.2.3 输出核心接口性能报告

2. 系统高可用保障措施

2.1 核心接口限流处理 (不依赖外部接口)

2.1.1 应用集成sentinel (参考CF文档)

2.1.2 应用对应的接口接入限流 (涉及代码改造)

2.1.3 打印限流日志 (便于设置告警)

2.1.4 sentinel管理端配置接口限流阈值 (参考: 接口最高的TPS * 20%)

2.2 核心接口超时处理, 熔断兜底治理 (依赖外部接口)

2.2.1 设置调用外部请求超时时间 (默认为3s)

2.2.2 打印超时日志 (便于设置告警)

2.2.3 应用集成sentinel (参考CF文档)

2.2.4 应用对应的接口接入熔断 (涉及代码改造)

2.2.5 跟业务确认好兜底方案

2.2.6 打印兜底日志 (便于设置告警)

2.2.7 sentinel管理端配置接口熔断阈值 (按 响应时间RT、异常数、异常比例 设置)

2.3 监控告警

2.3.1 接口调用量突增告警 (基于监控能力)

2.3.2 接口限流告警 (基于打印的限流日志告警)

2.3.3 调用外部接口超时告警 (基于打印的超时日志配置)

2.3.4 业务监控

2.4 应急预案准备

2.4.1 梳理核心接口异常风险点 (主要评估可降级的关键地方)

2.4.2 非业务手工降级 (配置Nacos动态配置开关手工降级)

2.4.3 接口层面直接降级 (配置Nacos动态配置开关手工降级)

2.5 每日健康巡检 (每天早上9点)

2.5.1 错误日志检查

2.5.2 应用指标检查 (CPU、内存fullgc、繁忙线程数)

2.5.3 中间件指标

2.5.3.1 redis。cpu、内存使用率、每秒命令写入数、redis慢查询

2.5.3.2 kafka。生产速率、消费速率、kafka积压情况

2.5.4 数据库指标。cpu、内存使用率、IO、慢SQL

2.5.5 接口指标。APM8大于1s接口、核心接口调用量峰值是否飙升

2.5.6 核心业务功能巡查

2.5.7 异常监控告警日志原因检查

- 事中处理 (快速恢复)

1. 应用重启。CPU打满、线程池打满

2. 升配。应用升级。CPU升配、内存升配、内存升配后JVM参数优化、应用节点扩容

3. 扩容。中间件扩容。redis扩容, redis增加主从节点; kafka扩分区, 增加kafka分区

4. 流量控制。开启应急预案接口关闭配置开关, 快速切断流量; 修改接口限流阈值, 限制流量

5. 版本回滚。 配置回滚 (挂载和Disconf配置); 应用版本回滚

6. 业务 (影响系统性能)下线。业务下线、知会产品或运营

- 事后复盘总结

分布式事务常见解决方案?

设计方案尽可能规避分布式事务 (相似的业务放在一起, 不要过度拆分)。分为强事务 (CP、低并发短事务)和柔性事务 (AP、高性能):

1. 强事务: 满足CP理论, XA协议 (2PC、JTA、JTS)、3PC。但由于同步阻塞, 处理效率低, 适合低并发、短事务业务。

  • 2PC: Seata(AT)、LCN(2PC), 适合分布式系统
  • JTA: atomikos, 适合单系统多数据源

2. 柔性事务: 满足AP, base理论。适合异步更新数据, 并且对数据的实时性要求极低的场景。主要分为:

  • 补偿性 (TCC、saga)
  • 最大努力通知型 (MQ、本地消息表)
  • 异步确保型 (MQ、本地消息表)

实现方式:

  • TCC(seata-tcc, lcn-tcc)
  • Saga (seata-saga状态机模式、Aop模式)
  • 本地事务消息
  • 事务消息MQ

互联网业务, 一般的流量比较大, 涉及很多高并发场景, 我们一般采用柔性事务, 这样的系统性能好。

mysql亿级大表如何优化?

- 紧急处理方案

指导原则:尽可能保证mysql业务库实例CPU不被打挂, 然后再考虑优化SQL语句或优化程序代码。

1. 找到锁表的慢SQL线程, 执行kill id

// 步骤1, 使用root账号, 查看当前正在运行的线。SHOW FULL PROCESSLIST

// 步骤2, 找到lock状态的线程ID:9874

// 步骤3, 执行kill命令, 停掉lock的线程

2. 找到耗时久的慢SQL线程, 执行kill id

// 步骤1, 查询执行时间超过10s的线程, 然后拼接成kill语句。select concat(‘kill ’, id, ‘;’) as ‘SQL’ from information_schema.processlist where command != ‘Sleep’ and time > 10 order by time desc;

// 步骤2, 得到步骤1的返回结果SQL’

// 步骤3, 执行步骤2返回的结果

3. 再次观察mysql cpu, 看看是否有明显的下降趋势

4. 通过explain执行计划分析慢SQL

4.1 优先考虑对大表加索引 (成本低, 见效快)

通过在线DDL, 给亿级大表添加索引或者更换索引 (对于千万级甚至亿级数据量的大表变更字段或索引, 一般大公司的做法是通过建新表、数据copy、rename、数据打平的方式进行平滑处理); 如果表数据量在百万以下, 可以不用等业务低峰期操作, 直接通过在线DDL添加或者更换索引 (先建新索引, 再删除索引), 可能会出现短暂的锁表

4.2 优化慢SQL

4.2.1 检查索引是否失效

检查索引字段是否存在如:类型转换、函数计算、全模糊查询、not in、or、索引项存在null值

4.2.2 检查高频查询字段是否建立联合索引

4.2.3 检查字段是否存在全模糊前缀查询。需要改为左模糊查询

4.2.4 检查SQL语句是否尽量走覆盖索引 (select字段只包含索引字段, 目的是减少回表查询数据行)

4.2.5 检查大表里是否使用类似LIMIT M,N深度分页查询, 以及对非索引字段进行排序

4.2.6 大数据量处理改为多次小批量处理

  • 缩小查询条件中查询时间的范围
  • 减少单次处理数据的大小

- 长期处理方案

1. 数据库扩容

2. 长期治理慢SQL

3. 单库迁移到分片库

使用分片库以后, 我们需要做哪些工作呢?

  • 数据迁移 (见下一个面试题)。通过canal + kafka + 数据处理应用, 完成亿级大表单库到分片库的平滑迁移。
  • 分库后的数据查询、插入、更新、删除

4. 夯实底盘监控与异常实时告警

- 扩展高频面试题

1. 假设白天业务高峰期出现mysql cpu告警, 但是没有慢SQL, 那我们的解决方案是?

答:通过监控看到数据库的连接数较平时大量增加, 但是没有明显的慢SQL, 这个时候大概率是我们的业务流量突增, 遇到这种情况, 我们第一时间要找运维对业务的数据库实例进行在线平滑扩容 (目前大公司的数据库或中间件基础都是部署在云上, 基于k8s进行扩容几乎秒级生效), 例如:数据库的硬件配置从2c 4G扩容到4c 8G。

2. 假设上了分片库以后, 有大客户单次写入TPS几w+订单, 那我们如何保证系统不被打垮呢?

答:1. 上层接口做好限流 2. 数据层增加拦截器, 单词写入或更新数据量超过1000条, 改为分批次提交事务 3. 底盘提供热点数据写入或更新的监控告警

mysql亿级大表单库如何平滑迁移到分库分表?

  • 如何保证不影响业务, 平滑过渡 (包括异常回滚)

1. 线上正常业务采用库双写方案, 完成切换后, 下线旧库, 平滑过渡

2. 旧库下线后, 离线报表调整到读新库, 保障业务不受影响

  • 分布式数据库如何选择

1. 市面上主要有shardingsphere、mycat、TiDB, 它们都支持mysql数据库。考虑到公司部分中心在使用TiDB有一些大坑, 风险不可控, 暂时不考虑引入。shardingsphere与mycat综合比较后, 我们选择shardingsphere, 对会员userId进行哈希取模分片, 128个分片库。

  • 历史数据如何迁移

1. 单库 --》单库迁移。适合源库与目标库的表字段可以一对一映射, 无需额外的开发工作量, 这个时候代码层不用改造。ETL工具有很多种: Flink CDC、Canal、DataX, 目前比较主流的是Flink CDC, 可以全量同步也可以增量同步。

2. 单库 --》 分片库迁移&&分片库 --》 分片库迁移。适合源库与目标表字段无法一对一映射, 这个时候代码需要改造, 应用测消费数据后, 对数据进行加工处理, 写入目标库。为了减少对目标库的冲击, 建议在晚上12点以后低峰期操作, 另外Kafka处理数据的线程不要开的太大。

  • 实时数据如何同步

目前有3种方案可选择。方案1无侵入性, 比较可靠, 优先考虑, 如果没有开放binlog, 考虑方案3

1. 监听mysql binlog日志。通过Flink CDC或Canal监听binlog数据, 写入kafka, 应用侧消费完成后, 更新新库。

2. 通过拦截mybatis的sql拦截器, 获取变更语句, 发送kafka变更消息。弊端: 对原有的系统有侵入性, 还会产生一定的性能损耗。此外, 如果执行过程中, 方法事务回滚, kafka无法回滚。

3. 新旧库双写。通过修改业务代码, 通过双写开关控制双写时机, 打开开关后, 变更完旧库再变更新库。

  • 数据一致性如何保证

通过对账程序自动对账, 同时配置人工告警, 防止对账失败, 人工介入。具体步骤如下:

1. 考虑到数据量很大, 我们通过大数据平台hive, 创建对账任务, 每天下半夜的凌晨1点对账新库、旧库数据, 不一致, 写入异常数据到差异表。同时配置人工告警, 通知相关的研发人员。

2. 通过ETL同步差异数表数据到mysql库差异表 (一般差异表数据量比较小)

应用侧通过定时任务定时读取mysql库差异表数据, 更新新库。

  • 落地实现

1. 通过ETL工具同步旧库数据到kafka

通过ETL工具Flink CDC同步表全量或增量数据到kafka。

2. 实时数据同步服务消费kafka数据后入新库

完成数据同步服务搭建, 多线程消费步骤1中的kafka数据, 按要求处理完数据后, 写入新库。

3. 创建hive对账任务, 比如新旧库, 异常数据写入大数据差异表, 同步在监控平台配置监控告警。

4. 同步大数据差异表数据到mysql数据差异表

5. 应用侧创建定时任务, 定时读取mysql差异表数据, 写入新库

写入新库的时候, 根据主键id将新库数据更新同旧库一致。

6. 持续自动对比数据, 发现不一致, 人工介入, 及时定位原因修复上线, 直到数据最终一致。

7. 打开开新库开关, 启用新库, 下线旧库和相关旧代码。 

亿级数据导入如何优化?

1. 推动产品优化功能

      • 导入客群-列表页面 (包括进度查询)
      • 导入客群-导入页面 (支持拆分多个文件, 页面设置好以后, 后端自动处理)
      • 导入客群-查看拆分客群页面

2. 控制单次上传客群文件的大小。如果导入数据超过系统最大处理能力, 那我们应该怎么办呢

      • 应用扩容
      • 引导运营错峰上传、分批在不同的时间段操作, 不要过于集中

3. 减少导入文件的大小

      • 优化导入文件格式 (excel格式改为txt格式)
      • 1亿数据的大文件拆分为多个文件上传
      • 单个大文件上传, 根据运营侧配置的客群拆分规则, 后台程序再自动拆分多个子客群

4. 通过多线程增加单机处理能力, 控制线程池大小, 避免内存OOM或CPU飙高

5. 通过Apache Commons提供LineIterator行迭代方式解决大文件内存OOM

6. 通过MQ kafka处理亿级客群明细数据削峰

      • 单次发送改为批量发送。封装一个生产工具类, 核心是用ArrayBlockingQueue实现, 达到指定条数或者达到指定时间就批量发送。
      • 减少单条报文大小。单条报文存储是json list结构, 考虑到网络IO, 报文不能太大, list建议不要超过100条。

7. 通过ES替换MySQL存储客群明细数据

8. 通过Redis管道方式提高redis写入客群明细数据效率

      • 通过redis Pipeline实现redis批量处理, 减少多次网络传输开销, 提升写入性能
      • 每次处理完, 代码sleep睡眠100ms, 防止短时间内redis cpu冲高

9. 优化JVM参数, 减少younggc和fullgc

      • 垃圾回收器切换, 将CMS替换为G1。例如: -Xx:+UseG1GC

G1为每一个Region设计了两个名为TAMS (Top at Mark Start)的指针, 把Region中的一部分空间划分出来用于并发回收过程中的新对象分配, 并发回收时新分配的对象地址必须要在这两个指针位置以上

      • 内存从6G调整到8G。例如: -Xmx8g -Xms8g。
      • Region的大小从2M调整到4M, 提升大对象的标准, 减少内存碎片。例如: -XX:G1HeapRegionSize=4M
      • MaxGCPauseMillis停顿时间从200ms调整到100ms。例如-XX:MaxGCPauseMillis=100ms
      • InitiatingHeapOccupancyPercent老年代使用占比从45%调整为40%, 尽早启动mixedGc, 尽早回收老年代内存 (G1年轻代和老年代都是copy对象, 因此回收年轻代、老年代成本差不多)

10. 运营侧优化 (非必选)

      • redis扩容。redis 3主3从 (16G)升级到5主5从 (32G)

线上突然遇到一个接口很慢怎么办?

一、背景

某一天早上, 正在上班路上, 突然间手机滴滴不断收到大量告警提醒, 赶紧查看了下告警信息, 结果显示某个接口出现大量超时, 平均响应时间超过3s, 这个时候怎么办, 是不是有点慌?

二、此类问题解决思路

出现生产问题, 我们绝不能马虎放过抱着侥幸心理, 必须要找到根本原因及时处理, 防止下次留下更大的坑。那我们的处理思路是什么呢?这里给大家分享一下

1. 定位问题

首先我们要快速定位接口的哪一个环节比较慢, 性能瓶颈在哪里?这个时候可以采用APM工具快速定位, 常见的工具: skywalking、pinpoint、cat、zipkin

假如我们应用没有接入APM, 可以在生产环境装一下阿里的Arthas, 利用trace接口方法, 大概能分析是哪一块比较慢, 定位的力度稍微有点粗糙

2. 解决办法

      • 扩容 (应用自动扩容、redis扩容、mysql在线扩容、kafka分区扩容)
      • 应用重启大法
      • 优化代码逻辑, 走hotfix发版解决

三、常见优化接口性能方案分析

1. 数据库慢SQL

通过explain执行计划分析下

  • 锁表 (先把缩表的慢SQL kill一波)
  • 未加索引
  • 加了索引, 索引失效 (对索引加方法转换、区分度很低比如枚举值、索引列大量空值)
  • 小表驱动大表 (尽可能过滤数据)
  • SQL太复杂 (join超过3张表或者子查询比较多, 建议拆分SQL为多个接口, 比如先从某个主接口查某张表数据, 然后关联字段作为条件从另外一张表查询, 进行内存拼接)
  • 返回的数据量太大 (可以分页多批次查询, 可以非c端接口考虑多线程查询)
  • 单表数据量太大 (考虑放分片库或分表或者clickhouse、es存储)

2. 调用第三方接口慢

  • 调用第三方设置合理的超时时间, 比如你的接口是高并发接口, 从自身对方接口的要求和对方线上P95接口的平均rt, 综合设置超时时间
  • 集成sentinel或hystrix限流熔断框架, 防止对方接口拖垮我们自己的接口
  • 事务型操作根据实际的情况酌情决定是否重试补偿 (本地消息表+job重试), 比如新增、修改等操作要考虑对方接口是否支持幂等, 防止超发
  • 循环调用, 改为单次批量调用, 减少IO损耗 (比如调用AB接口, 根据用户ID、分组ID多个, for调用改为一次传多个分组ID)
  • 缓存查询结果 (比如根据用户ID查询用户信息)

3. 中间件慢

  • redis慢 (是否有热key、大key。热key: 上本地缓存; 大key: 拆分大key或者采用set结构的ismember等方法判断 --- O(1)时间复杂度)
  • kafka慢 (生产端慢: 向kafka丢消息慢了, 可以使用阻塞队列接收, 批量丢消息等优化; 消费端: 增加消费节点、增加消费线程或批量消费批量写库)

4. 程序逻辑慢

  • 非法校验逻辑前置, 避免无用数据穿透消耗系统资源, 减少无效调用
  • 循环调用改为单次调用 (比如查数据库或其他rpc或restful接口, 能批量调用尽量批量调用, 数据在内存组装处理)
  • 同步调用改为异步调用 (改为CompletableFuture异步非阻塞, 并行调用不同的rpc接口)
  • 非核心逻辑剥离 (拆分大事务, 采用mq异步解耦)
  • 线程池合理设置 (千万不要创建无界队列线程池, 线程池满了以后要重写拒绝策略, 考虑告警加数据持久化)
  • 锁设置合理 (本地读写锁设计不合理或锁粒度太大, 分布式锁合理使用防止热点key)
  • 优化gc参数 (考虑young gc、full gc 太频繁、调整gc算法、新生代老年代比例)
  • 只打印必要日志 (warn或error级别)

5. 架构优化

  • 高并发读逻辑都走redis, 尽可能不穿透到db
  • 涉及写逻辑数据 (异步、批量处理、分库分表)
  • 接口接入限流熔断兜底 (sentinel或hystrix)
  • 监控告警 (error日志告警、接口慢查询或不可用或限流熔断告警、DB告警、中间件告警、应用系统告警)
  • 接口加动态配置开关快速切断流量或降级某一些非核心服务调用
  • 设计自动对账job, 保证数据自动可恢复

调用第三方接口需要考虑哪些点?

1. 封装统一的http请求工具类

  • 集成apache httpclient
  • 集成okhttp开源框架

2. 打印请求接口入参、出参、耗时日志 (可以在请求工具类统一打印)

3. 参数合法性校验 (减少对下游无效的调用)

  • 数据格式的校验 (例如非空、合法性判断)
  • 业务合法性的校验

4. 接口设置超时时间

  • http工具类提供配置的超时时间参数, 供其他业务场景传递, 另外不传递给一个默认的超时时间, 禁止不设置超时时间
  • 评估超时时间, 要结合业务, 不能拍脑袋, 比如可以根据接口监控, 看一下P95、P99的响应时间, 然后在这个基础上放大一点

5. 接口是否需要重试以及重试的次数要慎重 (要考虑下游接口提供的能力, 另外事务接口不要重试, 否则容易多发)

  • 事务类接口不要随便重试, 如果下游没做好接口幂等容易超发, 像活动的奖品发放。但是一定要有业务告警, 通过人工接入的方式, 进行补发处理
  • 查询类接口的重试, 也要慎重考虑, 如果下游接口支持的QPS有限, 重试放量, 容易把下游的接口打垮, 所以如果决定重试, 要调研下游接口的并发能力, 酌情处理

6. 事务一致性保障 (上下游确定唯一的ID字段、下游提供回查接口、本地事务消息表、job重试)

自身服务:

  • 调用下游事务类的接口 (例如新增或创建订单、修改订单), 上游根据下游接口的要求构造该字段
  • 调用下游接口失败根据业务需要判断是否回滚其他的业务或者记录业务数据到本地事务消息表, 然后启动一个job定时补偿数据
  • 管理后台提供一个事务消息表查询的功能, 可以在界面上手工点击进行人工补偿数据

下游服务:

  • 提供的接口定义一个唯一值的字段, 接口做好幂等处理
  • 提供一个根据唯一值的字段查询业务数据的接口, 可以在界面上手工点击进行人工补偿数据

7. 接口实现熔断返回兜底值及降级 (自动或手动降级, 注意不管是哪种方式实现, 都需要配置业务告警)

  • 自动熔断实现方案。接入开源的hystrix或sentinel框架, 配置合理熔断的阈值 (例如: 1分钟接口100个报错, 熔断30s), 实现超时或接口报错自动熔断, 同时跟业务商量给出一个默认的兜底值

例如调用大数据客群接口, 如果第三方提供的客群接口大量报错或服务超时, 为了保护我们自己的系统, 框架自动熔断同时返回默认不命中的兜底值

  • 手动熔断实现方案。接入开源的动态配置中心apollo或nacos, 配置熔断开关, 当收到大量业务告警, 动态秒级关闭, 恢复后打开开关

8. 多次调用改为单次批量调用

  • for循环根据id调用外部接口改为单次批量根据id调用, 减少多次网络io带来的时间消耗
  • 要考虑单次批量调用参数的大小, 比如单次传10个id参数, 这个也不行, 可能导致接口直接超时, 所以要对参数进行分组切割批量调用, 比如10个参数, 1次传5个, 分2次调用, 代码如下:

List<Integer> totalList = Lists.newArrayList(1,2,3,4,5,6,7,8,9,10);

List<List<Integer>> partitionList = Lists.partition(totalList, 5); // [[1,2,3,4,5], [6,7,8,9,10]]

partitionList.forEach(vo -> {

// todo 调用第三方

   });

9. 接口返回的数据考虑是否缓存 (防止对下游接口造成流量冲击)

  • 查询客户命中的客群标签, 同一人一天内理论上都是不变的, 可以使用redis缓存, 但是过期时间不适合太长, 因为同一个人使用系统的时间不长

如何做好功能设计?

一、产品需求澄清、PM排期及任务分解

二、开发设计流程图 (研发、测试参与)

1. 功能设计流程图 (与外部系统交互、本系统模块之间流程, 比较好用的画图软件draw.io或在线的process on)

2. 数据库设计 (从DDD角度界限上下文、ER图、评审表结构设计是否合理, 表的关联关系是否合理、是否创建索引、是否大数据量表考虑放到分片库以及分片字段设计)

3. 缓存设计

  • 缓存结构是否合理 (string、hash、list、set), 优秀实践:
    • 对于C端高并发接口, hash结构要慎重用, key不能用固定的key, 防止key倾斜, 可以采用string结构, value尽可能压缩下, 比如:

{“groupId”:”536363737”,”groupId”:”989898989”}改为” 536363737#989898989”, 大大节省redis空间

    • 对于数据量很大, 单条value报文很大的业务, 如果经常判断某个值是否存在, 可以考虑采用set结构, 利用sismember方法, 时间复杂度O(1)
  • 缓存过期时间是否合理 (不能拍脑袋胡乱给一个时间, 如果存放的数量大, 过期时间一定要给短一些, 比如20分钟或半小时, 否则增长很长, 打爆redis)
  • 缓存是否设计考虑预热和重建机制
  • 缓存中尽可能不能使用delete命令, 特别是存放数据量很大的hashsetlist, 容易阻塞redis, 引发事故, 替换方案: expire -1
  • 是否设计二级缓存 (本地缓存+分布式缓存, 实现方式caffeine或guava+redis), 对于数据量不大热点数据 (一般1w条以下, 单条报文10kb以下, 比如网点数据、航线数据)可以放本地缓存, 这些热点数据查询非常频繁, 容易造成redis倾斜, 本地缓存可以抗这种高并发流量
  • 防穿透处理
    • 提供c端的接口, 查询不到数据, 尽可能不要穿透到数据库, 防止打爆数据库, 查不到一定记得打印告警日志, 及时人工干预
    • 提前将数据库预热到缓存中 (系统启动预热或者运营后台有手动刷新缓存按钮或者有个job刷缓存)

4. job设计

  • job逻辑要考虑幂等设计
  • job逻辑尽可能轻量级, 不要太重, 导致执行逻辑很久 (如果确实逻辑比较复杂, 可以分拆job或者从代码层面优化, 例如: 分批并行处理、减少单次处理数据)
  • 构建数据到缓存类似的job, 尽可能选择时间在晚上12点以后, 尽量不要在白天进行, 因为白天流量很大, 重建缓存容易引起接口抖动
  • 对于定时执行的job, 设计执行时间的时候, 要慎重考虑线上整个job执行的时间, 根据这个时间配置cron表达式, 不要拍脑袋随意设置

不然本次job没执行完, 下一次job执行的时候容易错过。

  • 对于大表数据 (百万、千万甚至亿级), 可以利用xxljob、saturn等分布式调度框架的分片处理能力, 并行刷数据, 提高处理速度

5. 接口设计

  • 高并发接口必须在测试环境压测, 清楚地知道接口支持QPS, 另外根据压测报告给出的优化建议, 及时调整线上的配置, 代码优化
  • 提供给c端的接口, 要清楚曝光位置、然后根据接口可能预计调用的流量、压测的接口最大支持的QPS, 决定是否扩容、是否需要限流及兜底逻辑、是否熔断
  • 程序中涉及的异常信息, 及时配置错误监控告警, 遇事做到心中有数
  • 对于业务关键位置, 及时打印info级别的业务追踪日志, 如果是高并发接口, 要做好开关, 能秒级关闭, 因为打印日志也特别耗性能
  • 程序设计方面, 高并发接口, 尽可能使用缓存、能异步就异步 (一般用mq, 不要用多线程异步)、尽可能采用无锁设计防止线程lock CPU冲高, 批量写库
  • 接口前置逻辑提前, 尽早过滤掉不合规逻辑, 缩短接口整个调用链路时长, 提升接口整体吞吐量
  • 接口要考虑无状态设计、幂等设计 (考虑分布式锁唯一key)、安全设计 (接口方法签名、是否接第三方防刷服务判断是否黑产用户)
  • 对第三方服务做到0信任, 考虑降级、熔断, 与业务方确认好兜底逻辑处理
  • 工具类尽可能使用apache、google等出的、一些github小众的开源框架很多在高并发场景有bug和性能瓶颈
  • 接口的时间复杂度, 部分逻辑在设计的时候尽可能保证是O(1), 例如调用第三方接口, for循环调用的, 是否可以提到循环外, 传多个id批量调用一次, 时间复杂度从O(n)降为O(1)

6. 接口设计

  • 核心接口做好监控。例如: 调用量上涨/下跌80%、接口健康度监控 (5分钟error超过100次、5分钟接口超过1s 1000次、5分钟不可用次数超过1000次)
  • 应用监控。例如: 应用cpu、内存、gc、繁忙线程数、数据库连接池连接数监控告警
  • 中间件监控。例如: redis可用性、cpu-内存-流量-大key-热key-慢查询监控、kafka可用性-消息积压情况-丢失率监控告警
  • 数据库监控。例如: mysql cpu、慢sql监控告警
  • 业务监控。例如: 发券量暴涨或暴跌、活动注册人数同比或环比异常

7. 预案设计

  • 设计全局开关 (系统接近不可用, 通过配置中心全局开关快速切断接口流量, 保证系统可用)
  • 遇到系统故障, 非核心功能通过配置中心开关下线, 保证整体服务可用
  • 应用能自动扩容 (这里就要求应用做到无状态设计, 比如: 我们之前有一些应用有调用加密机, 但是新应用节点需要申请白名单, 这种设计方式就没法自动扩容)

线上CPU飙高如何处理?

一、背景

某一天下午业务高峰期, 突然收到线上服务CPU冲高, 线程池被打满, 几分钟之内, 服务很快进入假死状态, 系统频繁重启, 客户反馈小程序或APP各种系统异常。

二、应急过程

1. 运维确认前天晚上是否有版本更新, 回退版本重启应用, 发现系统仍然告警, 排除版本引起的故障。

2. 收到线上CPU和线程池打满的告警后, 从线上dump线程的运行情况, 开始定位问题。

3. 服务动态扩容CPU和内存, 调整JVM启动参数, G1启动参数调整为6G。

4.  redis收到频繁告警, CPU使用率达到100%, 流量监控1.8G/s。

5. 追踪发现业务上线了某个功能, 导致redis流量暴增, 系统接口响应非常慢, 线程很快被打满, 迅速通知业务下线功能。

6. 系统恢复正常。

三、原因分析

1. 业务方推广上了某功能, 导致应用流量大了3倍, QPS达到8000/s, 后分析代码发现, 之前某折扣策略规则都是存在redis中, 后面上的新业务, 策略规则中有一个AOI的维度有600个, 导致一条策略的报文大小大概为60kb, 这样的策略, 运营上了14个, 当业务高峰期, 大流量下请求redis, 导致redis cpu飙升非常快, 带宽很快被打爆, 而业务系统对redis依赖很重, 接口请求都被redis拖垮, 响应越来越慢。

2. 应用节点虽然full gc很少, 但是young gc非常频繁1s达到3次, 严重影响了系统性能

四、事后改进措施

1. 调整应用JVM大小及GC参数

2. 应用节点CPU及内存扩容

3. 考虑到折扣策略数据量没那么大, 针对redis大key, 优化缓存读写策略, 引入本地+redis的二级缓存, 优先读取本地缓存, 减少对redis的冲击

3.1 在应用启动的时候, 将折扣策略信息预热到本地缓存

3.2 管理后台上架或下架策略的时候, 广播消息, 刷新二级缓存

4. 本地缓存失效, 在应用中打印告警日志, 配合监控系统告警研发同学, 人工介入, 触发job, 重刷缓存, 保证本地缓存能快速恢复

5. 接口从缓存中获取的策略json字符串转为大对象, 在高并发下, 这一步非常耗时并且很容易触发young gc, 优化为全局静态变量存放大对象, 减少toList对象转换操作。例如:

String discountStr = discountCacheService.findStrategyList();

// 这一步非常耗时

List<StrategyDTO> strategyList = JsonUtil.toList(discountStr, StrategyListDTO.class);

遇到P0级别故障如何项目复盘?

一、事件回顾

1、2月12号20:00点,运营人员在动态优惠系统配置14条AOI优惠策略,其中多条优惠策略是面向全网用户生效。

… …

二、问题分析及解决办法

1、问题分析:

1)运营当天配置了14条AOI优惠策略,每条策略大概有300个AOI编码并且配置了AB分流规则。

2)优惠策略之前的策略数据都是存储在redis中,所以运营配置14条策略生效以后,redis接收到的报文瞬间变得非常大,原来策略的key快速升级为大key,客户端频繁请求redis大key,流量高达1.8G/s,很快系统的http线程池被打满,请求无法响应,系统进入假死状态。

2、解决办法:

1)优化redis大key:事发当晚,针对rediskey场景,引入本地缓存作为一级缓存,接口优先从本地缓存读取数据,减少对redis中间件的压力,优化后redis运行比较平稳。

2)增加系统异常预案处理:在配置中心增加动态关闭接口的开关,一旦出现系统即将不可用的情况,立即打开开关,进行人工降级,并返回兜底数据,等待系统资源正常后,再打开开关。

三、总结与反思

1、研发侧:

1)对redis重度依赖,redis有细微的抖动,服务接口响应时间波动非常大,系统风险大。

2)redis使用不规范,没有提前对redis大key作出预判,导致面对突发流量,系统扛不住。

3)没有形成一套标准的高并发和高稳定性的架构设计评审方案,由于动态优惠系统属于核心系统,加上每天请求的QPS很大,所以对于系统的稳定性设计要求极高,可能设计方案出现一点疏忽就会影响系统整体的稳定性。

4)性能压测没有真实模拟线上数据,导致压测结果有偏差,不能对研发同学优化性能做到精准支撑。

2、底盘运维侧:

目前我们所有的应用都已经上云,对集团底盘能力依赖较重,但集团提供的排查工具比较粗糙,没有顺手的工具可用,这里总结了几点如下:

JVM层面:

  • 获取dump线程文件:每次都需要找运维帮忙手工dump线程文件,人工介入比较慢。
  • 定位详细的性能瓶颈:目前实时在线分析应用指标的性能问题比较难,底盘提供的grafana指标监控,只能分析粗略的结果,不能准确定位是什么原因导致CPU飙高、频繁full gc、http线程被打满,排查问题周期较长。

中间件层面:

  • redis工具缺乏:目前底盘没有的redis慢查询、热key、大key等监控工具,导致redis线上排查问题很困难。
  • 运维没有开放底层中间件与数据库的监控告警给业务研发,导致业务研发不能及时收到告警作出合理的响应。

3、产品运营侧:

1)产品侧:负责系统的产品同学经常换人,系统缺乏沉淀,而且系统逻辑复杂度很高,新人熟悉底层逻辑的时间通常很久,这样就会导致出需求的时候考虑不完善漏掉一些核心逻辑。

2)运营侧:运营同学上架策略节奏过快,策略上线后经常面向全网用户,如果出现问题,会引起大量客诉,风险高。

四、行动与结果

1、研发测

1)输出一套标准的redis使用规范,包括redis存储规范、redis热key解决方案、redis大key解决方案,并在部门内进行宣导,避免其他组踩坑(平台组长主导,3月底完成)

2)开发redis热key、大key监控与告警的工具并且集成到鹰眼监控平台,方便部门研发人员第一时间定位问题(平台组长主导,3月上线)

3)后续产品需求进入研发前,必须事先有严格的技术方案评审,研发同学必须输出架构方案设计图、接口详细设计、数据库设计、性能与安全设计、服务限流与降级配置、监控告警配置、预案设计,文档沉淀到CF,由各组PM主导,相关的研发和测试人员必须参与(平台组长主导输出规范,3月底完成)

4)性能压测必须输出一套统一的标准,压测的测试场景与线上高度还原,接口如果达不到业务要求上线的标准,测试同学通过邮件报备,与PM商量后是否延期需求,等研发优化完达到指定的性能标准后再上线(测试组长主导,3月底完成)

2、底盘运维侧

1)JVM层面

  • 建议获取dump线程文件自动化,底盘可以在集团云提供一个可视化页面,研发同学可以随时下载系统异常时刻dump线程文件。
  • 建议集团云在k8s容器集成Arthas性能分析工具,很多互联网大厂在使用,可实时在线分析应用相关指标,非常方便。

2)中间件层面

  • 建议运维开放中间件和数据库的监控给业务研发,确保研发第一时间接收到信息进行处理,及时消除潜在的系统风险。
  • 建议底盘开发redis慢查询和告警功能,可以串联skywalking分布式链路工具一起输出,这样排查性能问题非常有用。

3、产品运维侧

1)产品侧

  • 建议产品同学尽可能固定并且有备份同学,稳定的团队有利于系统长期、持续的规划和沉淀。
  • 系统还有不少前任产品留下来的业务债务,建议产品同学预留足够的时间优先处理这些债务,否则做的越多错的越多,我理解现在的慢是为了将来的快,欲速不达。

2)运营侧

  • 后期上策略建议邮件抄送,建议说明策略面向的用户量和生效时间,便于研发提前监控数据以及是否根据系统支撑的最大QPS对系统进行扩容处理,同时增加审批的流程,比如运营负责人审批后才能上线策略。
  • 建议运维上线策略采用灰度发布的方式,发布当天,运营密切关注数据是否正常,确定没问题后第二天再推向全网用户。

如何设计亿级开放平台网关?

===========================================================

  • 9
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值