[关闭]
@yexiaoqi 2023-06-07T09:51:00.000000Z 字数 19899 阅读 570

整理

面试 技术


基础

String为什么被设计为不可变的?

HashMap的原理

HashMap 的原理就是用一个数组存储一系列 K-V结构的 Node 实体,存储的位置通过对 key 的 hashCode 进行计算得到。如果发生 hash 冲突,就将新的 Node 插入链表,链表过长超过阈值的话为了提高查询效率会转为红黑树(JDK 1.8以后)详见源码阅读-HashMap

Spring事务失效的12种场景

Spring 事务:再对应service层方法上使用@Transcational,分布式的话需使用分布式事务。下面几种情况会导致事务不生效

下面几种情况会导致事务不回滚

其他

大事务问题:事务执行耗时比较长,会导致死锁、锁等待、回滚时间长、接口超时、并发情况下数据库连接被占满、数据库主从延迟等问题

编程式事务,基于TransactionTemplate的编程式事务优点:避免由于 Spring AOP 导致事务失效的问题、能更小粒度的控制事务的范围,更直观

  1. @Autowired
  2. private TransactionTemplate transactionTemplate;
  3. public void save(final User user) {
  4. queryData1();
  5. queryData2();
  6. transactionTemplate.execute((status) => {
  7. addData1();
  8. updateData2();
  9. return Boolean.TRUE;
  10. })
  11. }

Spring的三级缓存机制

三级缓存分别是:
singletonObject:一级缓存,该缓存 key=beanName, value=bean;该 bean 是已经创建完成的,经历过实例化->属性填充->初始化以及各类的后置处理。一旦需要获取 bean 时,第一时间就会寻找一级缓存;
earlySingletonObjects:二级缓存,该缓存 key = beanName, value = bean;跟一级缓存的区别在于,该缓存所获取到的 bean 是提前曝光出来的,还没创建完成。获取到的 bean 只能确保已进行了实例化,但是属性填充跟初始化肯定还没有做完,因此该 bean 还没创建完成,仅仅能作为指针提前曝光,被其他 bean 所引用;
singletonFactories:三级缓存,该缓存 key = beanName, value = beanFactory;在 bean 实例化完之后,属性填充、初始化之前,如果允许提前曝光,spring 会将实例化后的 bean 提前曝光,也就是把该 bean 转换成 beanFactory 并加入到三级缓存。在需要引用提前曝光对象时再通过 singletonFactory.getObject() 获取。

具体参考当Spring AOP遇上循环依赖

三级缓存

Spring Event

Spring Event 使用观察者模式,事件源发布一个事件,事件监听器可以消费这个事件,事件源不用关注发布的事件有哪些监听器,可以对系统进行解耦。实现方式上有两种:

Spring 中事件监听器的处理默认是同步方式,可以改为异步,有两种方式

Spring 事件监听器消费事件同步模式下支持自定义顺序,一般在监听方法上使用@Order(1),数字越小优先级越高;异步模式下顺序不靠谱。
适用场景:异步模式将主逻辑和监听器逻辑分到不同线程中,适合非主要逻辑,因为监听器运行有可能失败。如果非主要逻辑要求必须成功,考虑使用更重量级的消息中间件来解决。
具体代码参考:@EventListener:定义 spring 事件监听器

多线程

自定义线程池的几个参数是什么?

线程池的执行流程?

线程池的执行流程

  1. 线程池刚创建时,里面没有线程,任务队列作为参数传进来

  2. 当调用 execute() 方法添加一个任务时,线程池会判断:

    • 运行的线程数量 < corePoolSize,则创建线程运行此任务
    • 运行的线程数量 >= corePoolSize,那么这个任务将放入队列
      • 队列已满 & 运行的线程数 < maximumPoolSize,则创建非核心线程运行此任务
      • 队列已满 & 运行的线程数 >= maximumPoolSize,则根据拒绝策略来处理
  3. 当线程完成任务时,会从队列中取一个任务来执行

  4. 当前运行的线程数大于 corePoolSize,线程空闲超过一定时间(keepAliveTime)时,那么这个线程就会被停掉。线程池所有任务完成后,线程数最终会收缩到 corePoolSize 的大小。

线程池的线程数如何配置?

具体参考 Java线程池实现原理及其在美团业务中的实践

从线程池角度考虑如何减少任务的阻塞时间?

多线程的业务场景

  1. 简单定时任务
    new Thread 里面放一个 while(true) 循环。优点是简单,缺点是功能单一,无法应对复杂场景

  2. 监听器
    搭配配置中心,修改配置开关后,动态刷新,服务端用开关判断是否启动业务逻辑处理

  3. 收集日志
    如果没有引入 MQ,可以通过多个线程/线程池异步写日志数据到数据库

  4. excel导入
    excel 导入读取数据后可能有复杂的业务逻辑需要处理,可以通过多线程处理。最简单的实现方式:parallelStream,它通过ForkJoinPool实现

  5. 统计数量
    count++ 并非原子操作,多线程执行时,统计次数可能出现异常。可以使用AtomicIntegeratomic包下面的类

  6. 查询接口
    接口中有串行远程调用的,可以改为并行调用

  1. public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
  2. final UserInfo userInfo = new UserInfo();
  3. CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
  4. getRemoteUserAndFill(id, userInfo);
  5. return Boolean.TRUE;
  6. }, executor);
  7. CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
  8. getRemoteBonusAndFill(id, userInfo);
  9. return Boolean.TRUE;
  10. }, executor);
  11. CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
  12. getRemoteGrowthAndFill(id, userInfo);
  13. return Boolean.TRUE;
  14. }, executor);
  15. CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
  16. userFuture.get();
  17. bonusFuture.get();
  18. growthFuture.get();
  19. return userInfo;
  20. }

并发

ReentrantLock 和 Synchronized 的区别?Synchronized 原理?

synchronized 锁升级过程

synchronized 有三种使用方式:

Java 对象结构分为 对象头、对象体、对齐字节。对象头包含三部分:

锁升级过程按照无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁方向升级,无法进行锁降级。

  1. 线程A在进入同步代码块前,先检查 MarkWord 中的线程 ID 是否与当前线程 ID 一致,如果一致,则直接无需通过 CAS 来加锁、解锁
  2. 如果不一致,再检查是否为偏向锁,如果不是,自适应自旋等待锁释放
  3. 如果是,在检查对应线程是否存在,如果不在,则设置线程 ID 为线程 A 的 ID,还是偏向锁
  4. 如果在,撤销偏向锁,升级为轻量级锁。线程 A 自旋等待锁释放
  5. 如果自旋到了阈值,锁还没释放,又有一个线程来竞争锁,此时升级为重量级锁。其他竞争线程都被阻塞,防止CPU空转
  6. 锁被释放,唤醒所有阻塞线程,重新竞争锁

聊聊 AQS(抽象队列同步器)? ReentrantLock 的实现原理?

Disruptor

Disruptor是一个高性能异步处理框架,能够在无所的情况下实现队列的并发。
使用环形数组实现了类似队列的功能,并且是一个有界队列。通常用于生产者-消费者场景

核心设计原理

优点

详细参考:高性能并发队列Disruptor使用详解

JVM

JVM内存模型?哪些是线程独享、哪些是线程共享?

jvm内存模型

堆、方法区是线程共享;虚拟机栈、本地方法栈、程序计数器是线程私有的

jvm加载类的时候的阶段、static成员变量在各个阶段中发生了什么变化

Java堆的分区,比例,如果调大比例后会有什么样的后果?

Java堆内存结构

默认的,Young:Old=1:2 (可通过参数 –XX:NewRatio 来指定)。其中,新生代(Young)被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to。老年代过小会导致频繁发生 fullGC,继而导致 STW
默认的,Eden:from:to = 8:1:1 (可通过参数 –XX:SurvivorRatio 来设定),新生代比例过小会导致频繁发生MinorGC

JVM的GC算法,为什么“标记-整理”更好一些,能解决什么问题?

标记-整理:标记出所有需要回收的对象,将所有存活对象向内存一边移动,清理掉边界以外的部分。
主要用于老年代,移动存活对象代价比较大,而且需要STW,从整体的吞吐量来考量,老年代使用标记-整理算法更合适。

什么时候对象进入老年代?

什么情况会触发 FullGC?

Java类加载机制?共有哪几种类加载器?

类加载过程 JVM 要:获取类的二进制字节流->结构化静态存储结构->在内存中生成 Class 对象
双亲委派模型:自己尝试加载,加载不了交给父类加载器加载

看线上正在运行的服务的GC日志,你需要输入什么指令?

jmap -histo:live PID | head -n20:查看对应进程对象中占用空间较大的前20个对象
jstat -gcutil -h20 PID 1000:查看堆内存各区域使用率及 GC 情况
jstat -gc PID 1000:查看 GC 次数时间等,一秒一次
jmap -dump:live,format=b,file=heap4.hprof PID:使用 jmap 生成 dump 文件后再详细分析

G1和CMS垃圾回收器的区别,哪几个过程中会STW? G1的region

  1. 使用范围不一样
    CMS:老年代,需要配合新生代Serial/ParNew一起使用
    G1:新生代、老年代
  2. STW 的时间
    CMS:以最小停顿时间为目标
    G1:可预测垃圾回收的停顿时间
  3. 垃圾碎片
    CMS:使用“标记清除”算法,容易产生内存碎片
    G1:“标记整理”算法,没有内存碎片
  4. 垃圾回收过程不一样
    CMS:初始标记(STW)、并发标记、重新标记(STW)、并发清除
    G1:初始标记(STW)、并发标记、最终标记(STW)、筛选回收(STW)
  5. CMS会产生浮动垃圾

G1将Java堆划分为2048个大小相同的独立 Region 块,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理

JVM 调优

什么时候考虑 JVM 调优?

JVM 调优的目标

JVM 调优量化目标

JVM 调优的步骤

  1. 分析 GC 日志及 dump 文件,判断是否需要优化,确定瓶颈问题点;
  2. 确定 JVM 调优量化目标;
  3. 确定 JVM 调优参数(根据历史 JVM 参数来调整);
  4. 依次调优内存、延迟、吞吐量等指标;
  5. 对比观察调优前后的差异;
  6. 不断分析和调整,直到找到合适的 JVM 参数配置;
  7. 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪。

常见的 JVM 参数

堆栈配置相关

垃圾收集器相关

辅助信息

常用调优策略

MySQL

SQL查询语句是如何执行的?

MySQL的逻辑架构图
一条查询语句的执行过程一般是经过连接器、分析器、优化器、执行器等功能模块,最后到达存储引擎

SQL更新语句是如何执行的?

执行语句前要先连接数据库,分析器会通过词法和语法解析知道这是一条更新语句,会把表T上所有缓存结果都清空。优化器决定要使用哪这个索引,然后执行器负责具体执行,找到这一行,然后更新。

MySQL默认的事务等级是什么?如何开启慢查询?

MySQL事务隔离级别:读未提交、读已提交、可重复读(默认)、串行化。可重复读会导致幻读,InnoDB 通过多版本并发控制机制(MVCC)解决了该问题。

默认情况下,MySQL 没有开启慢查询日志,如需永久开启,则要修改mysql的配置文件,在 [mysqld] 下增加slow_query_log=1slow_query_log_file=/usr/local/mysql/data/slow.log,并重启 mysql 服务。详细参见MySQL慢查询日志如何开启以及分析

B+ 树和 B树的主要区别?B+ 树索引,一次查找过程?

B+ 树是有序的,和B-树的主要区别:

假设由如下SQL:age 加个索引,这条 SQL 是如何在索引生执行的?

  1. select * from Temployee where age=32;

idx_age二级索引树

id主键索引树

这条 SQL 查询语句的执行大概流程:

MongoDB为啥用B树而不是B+树

InnoDB 存储引擎中的行锁的加锁规则

InnoDB 三种行锁:
- Record Lock(记录锁):锁住某一行记录
- Gap Lock(间隙锁):锁住一段左开右开的区间(m, n)
- Next-key Lock(临键锁):锁住一段左开右闭的区间(m, n]

  1. 对于 update、delete、insert,InnoDB 会自动给相应记录行加写锁
  2. 对普通 select 语句不加锁,但是在 Serializable 隔离级别下会加行级读锁
  3. select …… for update加行级写锁
  4. select …… lock in share mode加行级读锁

查找过程中访问到的对象才会加锁
加锁的基本单位是临键锁

数据库索引优化?索引的弊端?

创建索引会使查询操作边得更快,但会降低增删改的速度,因为执行这些操作的同时会对索引文件重新排序或更新

数据库优化

分库分表

缺点
- 事务问题,不能用本地事务,需要分布式事务;
- 跨节点 Join 的问题:解决这一问题可任意分两次查询实现;
- 跨节点的 count、order by、group by 以及聚合函数问题:分别在各个节点上得到结果后在应用程序端进行合并;
- ID 问题:数据库被切分后,不能再依赖数据库滋生的主键生成机制,最简单可以考虑 UUID;
- 跨分片的排序分页问题(后台加大 pagesize 处理)

  1. 为什么要分库、分表
    分库:拓展磁盘存储、并发连接支撑
    分表:单表大量数据时,存储和查询性能会遇到瓶颈,考虑分表分摊压力
  2. 什么时候考虑
    • 阿里《Java开发手册》中推荐
      单表行数超过500万行或者单表容量超过2GB,才推荐进行分库分表。
    • 根据业务提前预估
  3. 以什么维度来分表
    先找到业务的主题,比如表是一张客户信息表,可以考虑以客户id来分表
  4. 非分表键如何查询
    • 遍历:简单粗暴(不建议)
    • 将用户信息冗余同步到ES,通过ES来查询(推荐)
  5. 分表策略
    • 范围划分,比如以订单id,以订单作为分表键,300w一个表;或者时间,通常用于冷热数据分离
      优点:扩容方便
      缺点:会有热点问题
    • Hash取模,以指定的路由key(比如用户id、订单id)取哈希后对分表数取模,将数据分散到各个表中
      优点:不会存在明显的热点问题
      缺点:扩容比较麻烦,需提前规划好
  6. 跨节点join关联问题
    解决思路:
    • 字段冗余
    • 全局表,基础表每个库中保存一份
    • 数据同步
    • 应用层代码组装
  7. 聚合函数问题:分别在各个节点上得到结果后,在中间件/应用端合并
  8. 分库分表后的分页问题
    • 全局视野法:在各数据库查到对应结果后再代码端汇聚再分页。缺点是会返回过多数据
    • 业务折衷:禁止跳页查询,需要业务妥协
  9. 分布式ID:考虑使用雪花算法生成
  10. 分库分表中间件:推荐使用 Apache ShardingSphere
  11. 分表要停服吗?不停服如何做?
    • 编写代理层,加个开关,灰度期间还是访问老的DAO
    • 发版全量后,开启双写,既在旧表新增和修改,也在新表新增能够和修改。日志/临时表记下新表ID起始值,旧表中小于这个值的数据就是存量数据,这批数据就是要迁移的
    • 通过脚本把旧表的存量数据写入新表
    • 停读旧表改读新表,此时新表已经城在了所有读写业务,但是这时候不要立即停写旧表,保持双写一段时间
    • 当读写新表一段时间后,如果没有业务问题,就可以停写旧表了

双机热备

网络/IO

TCP 怎么实现拥塞控制?

负载均衡

有几种 IO 模型?

NIO 和多路复用的区别?

零拷贝

缓存

缓存雪崩、缓存穿透、缓存击穿,如何应对?

雪崩:缓存机器宕机,导致请求全部打到数据库上,打死数据库
解决方案:
- 事前: Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃
- 事中:本地缓存 + 限流&降级(限制最大通过请求,超出的降级),避免数据库被打死
- 事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速回复

穿透:大量构造的恶意请求,导致缓存查不到,最终打死数据库
解决方案:在缓存前面增加布隆过滤器,将数据库所有可能的数据哈希到不空过滤器,查缓存的时候先用布隆过滤器筛一遍

  • key不存在,则key非法,直接返回
  • key存在,再继续查缓存

击穿:热点key再失效的瞬间,大量请求直接请求到数据库,打死数据库
解决方案:

  • 数据基本不更新,可以设置该热点数据永不过期
  • 数据更新不频繁&缓存刷新耗时比较少,可以用本地锁/分布式锁保证少部分请求过去并更新缓存,其余线程可以在锁释放后访问到新缓存
  • 更新频繁&刷新耗时较长,可以用定时线程在缓存过期前主动延后缓存过期时间

redis的ZSet使用场景和原理

redis 内存满了会发生什么?

  1. 如果 redis 配置了内存淘汰策略,则会通过定时删除惰性删除定期删除等方式淘汰过期的key,如果配置的淘汰策略淘汰不掉,则报错OOM
  2. 如果启用了虚拟内存机制,会将部分最老数据的value持久化到磁盘

Redis 发布订阅(pub/sub)

类似于 RocketMQ中广播模式,简单易上手

优点:轻量级、低延迟、低可靠性
缺点:

使用需考虑:消费者并发数、生产的速度和消费的速度、是否需保证可靠性

如果要求可靠性 & redis版本>=5.0,可以考虑使用 Redis Stream,如果有更复杂的要求,还是考虑使用专门的消息队列(kafka、RocketMQ、Pulsar等)

什么是一致性Hash算法

一致性 Hash 算法能够在 Hash 输出空间发生变化时,引起最小的改动。应用在像分布式系统扩容缩容的时候。

原理是将整个哈希输出空间设置为一个环形区域,对数据进行 Hash 操作,映射在环形区域上,然后让该值沿顺时针方向移动,遇到的第一个服务器就是它分配的节点。

当服务节点很少的时候,会有很多 key 被分配到同一个服务实例上,称之为“数据倾斜”。如何解决?虚拟节点,即对内阁服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称之为虚拟节点。具体做法可以在服务器 ip 或主机名的后面增加编号来实现。同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射。虚拟节点越多数据月均匀,一般虚拟节点数要在32个以上

虚拟节点

ELK

搞懂ELK并不是一件特别难的事

消息队列

如何保证消息队列消费的幂等性?

定时任务

如何避免被重复调度?

一次调度任务只有一个机器执行,不会因为是分布式部署而出现多台机器同时执行某个job。思路是使用分布式锁

基于数据库实现分布式锁

QuartzacquireTriggersWithinLock=true获取 trigger 的时候上锁(抢占式获取数据库锁并由抢占成功的节点负责运行),默认是 false,使用乐观锁,但可能出现 ABA 导致重复调度

XXL-JOB:使用数据库悲观锁

  • setAutoCommit(false)关闭隐式自动提交
  • select lock for update显式排他锁,其他事务无法进入&无法实现for update
  • 读数据库任务信息 -> 拉任务到内存时间轮 -> 更新数据库任务信息
  • commit提交事务,同时释放for update排他锁(悲观锁)

基于缓存(redis)实现分布式锁

  1. 获取锁的时候,使用 setnx 命令设置锁、设置过期时间,value 作为一个随机字符串,解锁时用来判断
  2. 获取锁的时候,增加超时时间
  3. 解锁时通过随机字符串判断是不是同一把锁

性能高,但redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。

基于 zookeeper 实现分布式锁

  1. 创建目录 mylock
  2. 线程 A 想获取锁,就在 mylock 目录下创建临时节点,会返回一个数字
  3. 获取 mylock 目录下的所有节点,获取比自己小的兄弟节点,如果不存在说明当前线程的序号最小,那么他就获取了当前这把锁
  4. 线程 B 创建一个临时顺序节点,判断自己是不是最小节点,设置监听比自己小一位的节点
  5. 线程 A 处理完毕,删除自己的节点。线程 B 监听到了变更事件,判断自己是不是最小的,如果是则获取到锁

可靠性高,但性能不如redis分布式锁

分布式

分布式事务如何解决?TCC?

集中解决方案:

TCC 优点:可以控制数据库操作的粒度,降低了锁冲突,可以提升性能;
TCC 缺点:应用侵入性强,需要根据网络、系统故障等不同失败原因实现不同的回滚策略,实现难度大,一般借助 TCC 开源框架,例如 ByteTCC、TCC-transaction、Himly。

二阶段消息:参考 DTM 框架

分布式接口幂等如何处理?

核心思想:通过唯一的业务单号保证幂等性,非并发情况下,查询业务单号有没有操作过,没有则执行操作;并发情况下,此过程需加锁。

根据唯一业务号取更新数据:通过版本号来控制。用户查询出要修改的数据,系统将数据返给页面,将数据版本号放入隐藏域,用户修改数据提交时将版本号一同提交,后台使用版本号作为更新条件

  1. update set version=version+1, xxx=${xxx} where id=xxx and version=${version};

没有唯一业务号的 update 与 insert 操作:进入到注册页时,后台统一生成 Token,返回前台隐藏域中,用户在页面提交时将 Token 一同传入后台,使用 Token 获取分布式锁,完成 insert 操作,执行成功后不释放锁,等待过期自动释放。

其他

调优

影响系统响应慢的原因有哪些?如何排查和解决的?

  1. 慢SQL
  2. 系统负载过高
  3. Full GC 次数过高
  4. 程序异常,死锁等
  5. 网络原因

参考 Java问题排查

Tomcat 调优

WebSocket

JSR-356 规范,即 Javax WebSocket,定义了 Java 针对 WebSocket 的 API,主流的 Web 容器都已经提供了 JSR-356 的实现,例如说 Tomcat、Jetty、Undertow 等等。

目前提供 WebSocket 服务的项目中一般有几种方案:

一般非 IM 即时通讯项目,使用前两种都可以。具体参考:

断线重连

为何会断线?

如何判断在线、离线?

  1. 当客户端第一次发送请求到服务端是会携带唯一标识、时间戳,服务端到数据库/缓存中去查询请求的唯一标识,如果不存在就入库;
  2. 第二次客户端定时再次发送请求携带唯一标识、时间戳,服务端查询唯一标识,如果存在就把上次的时间戳拿出来,判断间隔毫秒数是否大于指定时间,小于就是在线,否则就是离线;

如何解决断线问题?

客户端心跳检测:
1. 客户端定时通过管道发送心跳,发送时启动一个超时定时器
2. 服务端接收到心跳包,应答一个pong
3. 如果客户端收到服务端的应答包,则说明服务端正常,删除超时定时器
4. 如果客户端的定时器超时依然没有收到应答包,说明服务端挂了

服务端心跳检测:
1. 客户端定时通过管道发送心跳
2. 服务端记录每个用户最后一次心跳时间,并配置一个心跳最大间隔时长
3. 开启一个定时任务,根据每个用户最后一次心跳间隔时间和最大间隔时长来判断用户是否断线
4. 遇到超过最大间隔时长的直接剔除会话

如何保证消息一定送达给用户?

基于滑动窗口 ACK 。整体流程如下:

这种方式,在业务被称为推拉结合的方案。此种方案客户端和服务端不一定需要使用长连接,也可以使用长轮询所替代。客户端发送带有消息版本号的 HTTP 请求到服务端。

线上故障

故障1

事故情况:由于redis内存报警,导致接口失败率上升(没有配置redis拒绝策略,接口阻塞)。询问发现是上游同事进行了发布,于是迅速回滚,同时增大redis内存。
措施:让上游服务回滚;对redis扩容增大内存
事故原因:上游服务的对账系统调用自己的接口
1. 没有考虑到对账数据范围(进行了全量对账,存在冷数据)
2. 在业务高峰时期对账
3. 自己redis没有设置合理的淘汰策略

故障2

事故情况:MQ消费者服务OOM,致使服务直接挂掉
措施:重启服务,先暂时恢复
事故定位:查看 prometheus 上的服务监控,从某时刻开始内存明显飙升 && dump内存快照

  1. 根据时刻判断业务操作,当时正在执行导入导出excel
  2. 服务没挂,使用jmap -dump:live,format=b,file=/tmp/dump.hprof PID
  3. 服务已挂:需提前配置 JVM 自动 dump-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/app/oom
  4. 打开 VisualVM 分析发现org.apache.poi.xssf.usermodel.XSSFSheet类的对象占用的内存是最多,与之前推测吻合。找到相关代码
  1. XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file));
  2. XSSFSheet sheet = wb.getSheetAt(0);

解决:改用 SXSSFWorkbook

  1. XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file));
  2. SXSSFWorkbook swb = new SXSSFWorkbook(wb,100);//使用SXSSFWorkbook包装,一次读入内存100条记录
  3. SXSSFSheet sheet = (SXSSFSheet) swb.createSheet("sheet1");
  4. //省略中间代码
  5. sheet.flushRows();

后续思考:使用 EasyExcel。MQ异步导入excel,并发量大的话仍然会出现OOM,有安全隐患,调整mq消费者线程池,设置成4个线程,避免消费者同时处理过多消息、

故障3

事故情况:有个服务内存使用率不断飙高,主要逻辑就是消费MQ的数据然后持久化
措施:重启应用,但无果
事故定位:查看监控数据发现老年代内存发生 GC 也一直居高不下,查看jstat日志发现老年代内存回收不了。尝
试在本地复现。

  1. 调小JVM内存,消费MQ数据处使用while循环不断生成数据
  2. VisualVM 连上应用实时监控内存、GC 的使用情况,跑十几分钟并未发现相同问题
  3. 检查代码逻辑,发现逻辑是批量一次几百条数据
  4. 修改模拟逻辑发现GC飙高,内存无法回收,问题复现
  5. dump堆内存使用情况,发现com.lmax.disruptor.RingBuffer对象内存占用很大
  6. 消费MQ数据一次取几百条数据扔给RingBuffer,考虑到扔给RingBuffer上的数据只有被覆盖了才会回收掉内存,检查RingBuffer配置1024*1024,目测是这的问题

解决:调小 Disruptor 的 RingBuffer 配置,调整到多少得根据业务具体情况配置

故障4

事故情况:生产某个服务版本升级的时候,实例拉起后CPU占用飙高触发报警,响应缓慢。鉴于k8s配置的是滚动更新,导致响应缓慢持续了10分钟左右
措施:发布改到周末或晚上请求少的时候
事故定位:由于其他服务发布没有出现这种现象,考虑是不是应用版本有问题。后与开发确认版本没问题,进入容器中在线定位,
1. top找出 CPU 占用最高的 PID
2. ps -ef | grep PID何查看对应的应用
3. jstack -l PID >> PID.log获取进程堆栈信息
4. ps -mp PID -o THREAD,tid,time拿到占用CPU最高的 tid
5. printf "%x\n" tid获取16进制的线程 TID
6. grep TID -A20 PID.log确定是线程哪里出了问题
发现有几个C2 ComilerThread线程 CPU 占用比较高,但这几个不是应用线程。

Java 把编译过程分为两个阶段:编译期(javac编译成通用的字节码)、运行期(解释器逐条将字节码解释为机器码来执行)。
为了优化,HotSpot 引入了 JIT 即时编译器:
- 解释器:当程序启动时使用解释器快速执行,节省编译时间
- JIT编译器:程序启动后长时间提供服务时,JIT将越来越多的代码编译为本地机器码,获得更高的执行效率。
由于应用重启,大量的代码被识别为热点代码,触发了即时编译,造成CPU使用率飙高。

解决:jdk1.8默认打开分层编译,有一下几种办法
- 关闭分层编译:-XX:-TieredCompilation -client关闭分层编译,效果不好
- 预热:提前录制流量/流量控制预热/龙井预热(Jwarmup功能)
- 调高JIT阈值:-XX:CompileThreshold=5000修改JIT编译阈值为5000,资源不多的话可以使用
- 调整服务资源上限:调大CPU的limit,需要CPU就给够。有资源可以弹性的话可以使用

亮点

Excel 导入导出

使用MQ导出

详细参考:百万级数据excel导出功能如何实现?
导入功能比导出麻烦点,需要各种校验,留出接口方便业务校验扩展

多平台统一的消息推送系统

socket服务主要维护客户端的长连接,它能力决定了一次可以在线多少用户。需要集群部署。
route服务负责将消息分发到各个平台

客户端连接socket服务的流程
1. 客户端问路由服务要一个可用的socket服务器ip+端口
2. 路由服务通过合适的负载均衡算法得到一个socket服务器ip+端口,返回给客户端
3. 客户端向拿到的socket服务发起长连接
4. 连接成功后,对应的socket服务器维护服务级别的session信息,然后向路由服务汇报,路由服务保存该客户端和socket服务的对应关系
5. 客户端与对应的socket服务保持一定频率的心跳,并在心跳失败判定连接断开后重新发起连接,直到连接成功

客户端上线后,向其他客户端投递消息的流程
1. 客户端请求路由服务提供的消息投递接口
2. 路由服务根据消息上的目标客户端id,找到对应的socket服务,并向该服务投递消息
3. socket服务从自己维护的socket里找到目标客户端,最终完成消息投递

Q:路由服务如何通过负载均衡算法找到一个可用的socket服务器ip和端口?
A:socket服务提供一个查询本机ip和端口的接口,路由服务请求此接口时通过自定义的负载均衡算法选择一个可用的服务器ip和端口

Q:消息投递的时候,路由服务如何根据目标客户端id找到对应的server服务?
A:在客户端与某个server服务连接成功(上线)后,客户端与server服务之间的关系需要保存(数据库/redis)。然后写一个feign的拦截器,根据目标客户端的id获取server服务id,加到当前线程里

Q:如何优化性能
A:路由服务要承担路由上下线消息推送及其他业务,因此服务内部使用了Disruptor环形队列做异步处理,尽可能让消息推送接口更快返回。如果并发量比较高,可以加入消息队列,路由服务直接消费消息,以此来提升服务整体性能。

系统设计(System design)

网关系统

参考:https://mp.weixin.qq.com/s/tl4D4Kc7r4wL7aouQYZC4g

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注