@yexiaoqi
2023-06-07T17:51:00.000000Z
字数 19899
阅读 720
面试
技术
HashMap 的原理就是用一个数组存储一系列 K-V结构的 Node 实体,存储的位置通过对 key 的 hashCode 进行计算得到。如果发生 hash 冲突,就将新的 Node 插入链表,链表过长超过阈值的话为了提高查询效率会转为红黑树(JDK 1.8以后)详见源码阅读-HashMap。
Spring 事务:再对应service层方法上使用@Transcational
,分布式的话需使用分布式事务。下面几种情况会导致事务不生效
访问权限不是 public
方法用 final 修饰/是static
spring 事务底层通过 jdk 动态代理/cglib,帮我们生成代理类,在代理类中实现事务功能;如果某个方法用 final 修饰/是static,在它的代理类中就无法重写该方法
同一个类中的方法内部调用
未被 Spring 管理
多线程调用(不同的线程拿到的数据库连接不同,事务必然失效)
表不支持事务(比如使用 MyISAM)
未开启事务
下面几种情况会导致事务不回滚
REQUIRED
:如果当前上下文中存在事务,则加入该事务,如果不存在,则创建一个事务。只有这三种传播特性才会创建新事务:REQUIRED
,REQUIRES_NEW
,NESTED
RuntimeException
(运行时异常)和Error
(错误),对于普通的 Exception(非运行时异常)不会回滚propagation=Propagation.NESTED
),但将整个事务都回滚了,如何处理?使用 try……catch 包住嵌套事务内的异常不往外抛其他
大事务问题:事务执行耗时比较长,会导致死锁、锁等待、回滚时间长、接口超时、并发情况下数据库连接被占满、数据库主从延迟等问题
编程式事务,基于TransactionTemplate
的编程式事务优点:避免由于 Spring AOP 导致事务失效的问题、能更小粒度的控制事务的范围,更直观
@Autowired
private TransactionTemplate transactionTemplate;
public void save(final User user) {
queryData1();
queryData2();
transactionTemplate.execute((status) => {
addData1();
updateData2();
return Boolean.TRUE;
})
}
三级缓存分别是:
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 Event 使用观察者模式,事件源发布一个事件,事件监听器可以消费这个事件,事件源不用关注发布的事件有哪些监听器,可以对系统进行解耦。实现方式上有两种:
ApplicationListener
接口@EventListener
注解Spring 中事件监听器的处理默认是同步方式,可以改为异步,有两种方式
@Async
注解Spring 事件监听器消费事件同步模式下支持自定义顺序,一般在监听方法上使用@Order(1)
,数字越小优先级越高;异步模式下顺序不靠谱。
适用场景:异步模式将主逻辑和监听器逻辑分到不同线程中,适合非主要逻辑,因为监听器运行有可能失败。如果非主要逻辑要求必须成功,考虑使用更重量级的消息中间件来解决。
具体代码参考:@EventListener:定义 spring 事件监听器
corePoolSize
:核心线程数。当线程池中线程数 < corePoolSize 时,默认是添加一个任务才创建一个线程池;当线程数 = corePoolSize 时,新任务会追加到工作队列(workQueue)中。
maximumPoolSize
:允许的最大线程数(非核心线程数 + 核心线程数)。当工作队列也满了,线程池中总线程数 < maximumPoolSize 时就会增加线程数量。
keepAliveTime
:非核心线程(maximumPoolSize - corePoolSize)闲置下来的最多存活时间。
unit
:线程池中非核心线程存活时间的单位
workQueue
:线程池等待队列,维护着等待执行的 Runnable 对象。当运行线程数 = corePoolSize 时,新的任务会被添加到 workQueue 中,如果 workQueue 也满了,则尝试用非核心线程执行任务,等待队列应该尽量用有界的。常用的阻塞队列:
threadFactory
:创建新线程时的工厂,可以用来设定线程名、是否为守护线程等。
handler
:corePoolSize、workQueue、maximumPoolSize 都不可用的时候执行的拒绝策略。
线程池刚创建时,里面没有线程,任务队列作为参数传进来
当调用 execute() 方法添加一个任务时,线程池会判断:
当线程完成任务时,会从队列中取一个任务来执行
当前运行的线程数大于 corePoolSize,线程空闲超过一定时间(keepAliveTime)时,那么这个线程就会被停掉。线程池所有任务完成后,线程数最终会收缩到 corePoolSize 的大小。
简单定时任务
new Thread 里面放一个 while(true) 循环。优点是简单,缺点是功能单一,无法应对复杂场景
监听器
搭配配置中心,修改配置开关后,动态刷新,服务端用开关判断是否启动业务逻辑处理
收集日志
如果没有引入 MQ,可以通过多个线程/线程池异步写日志数据到数据库
excel导入
excel 导入读取数据后可能有复杂的业务逻辑需要处理,可以通过多线程处理。最简单的实现方式:parallelStream
,它通过ForkJoinPool
实现
统计数量
count++ 并非原子操作,多线程执行时,统计次数可能出现异常。可以使用AtomicInteger
等atomic
包下面的类
查询接口
接口中有串行远程调用的,可以改为并行调用
public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
final UserInfo userInfo = new UserInfo();
CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
getRemoteUserAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
getRemoteBonusAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
getRemoteGrowthAndFill(id, userInfo);
return Boolean.TRUE;
}, executor);
CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
userFuture.get();
bonusFuture.get();
growthFuture.get();
return userInfo;
}
synchronized 有三种使用方式:
Java 对象结构分为 对象头、对象体、对齐字节。对象头包含三部分:
锁升级过程按照无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁方向升级,无法进行锁降级。
Disruptor是一个高性能异步处理框架,能够在无所的情况下实现队列的并发。
使用环形数组实现了类似队列的功能,并且是一个有界队列。通常用于生产者-消费者场景
核心设计原理
优点
详细参考:高性能并发队列Disruptor使用详解
堆、方法区是线程共享;虚拟机栈、本地方法栈、程序计数器是线程私有的
默认的,Young:Old=1:2 (可通过参数 –XX:NewRatio 来指定)。其中,新生代(Young)被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to。老年代过小会导致频繁发生 fullGC,继而导致 STW
默认的,Eden:from:to = 8:1:1 (可通过参数 –XX:SurvivorRatio 来设定),新生代比例过小会导致频繁发生MinorGC
标记-整理:标记出所有需要回收的对象,将所有存活对象向内存一边移动,清理掉边界以外的部分。
主要用于老年代,移动存活对象代价比较大,而且需要STW,从整体的吞吐量来考量,老年代使用标记-整理算法更合适。
System.gc()
时,会告诉 JVM 可以GC<
历代晋升到老年代的对象的平均大小时,会触发Full GC 来让老年代腾出更多空间类加载过程 JVM 要:获取类的二进制字节流->结构化静态存储结构->在内存中生成 Class 对象
双亲委派模型:自己尝试加载,加载不了交给父类加载器加载
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将Java堆划分为2048个大小相同的独立 Region 块,每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理
堆栈配置相关
垃圾收集器相关
辅助信息
一条查询语句的执行过程一般是经过连接器、分析器、优化器、执行器等功能模块,最后到达存储引擎
执行语句前要先连接数据库,分析器会通过词法和语法解析知道这是一条更新语句,会把表T上所有缓存结果都清空。优化器决定要使用哪这个索引,然后执行器负责具体执行,找到这一行,然后更新。
MySQL事务隔离级别:读未提交、读已提交、可重复读(默认)、串行化。可重复读会导致幻读,InnoDB 通过多版本并发控制机制(MVCC)解决了该问题。
默认情况下,MySQL 没有开启慢查询日志,如需永久开启,则要修改mysql的配置文件,在 [mysqld] 下增加slow_query_log=1
、slow_query_log_file=/usr/local/mysql/data/slow.log
,并重启 mysql 服务。详细参见MySQL慢查询日志如何开启以及分析
B+ 树是有序的,和B-树的主要区别:
假设由如下SQL:age 加个索引,这条 SQL 是如何在索引生执行的?
select * from Temployee where age=32;
这条 SQL 查询语句的执行大概流程:
InnoDB 三种行锁:
- Record Lock(记录锁):锁住某一行记录
- Gap Lock(间隙锁):锁住一段左开右开的区间(m, n)
- Next-key Lock(临键锁):锁住一段左开右闭的区间(m, n]
select …… for update
加行级写锁select …… lock in share mode
加行级读锁查找过程中访问到的对象才会加锁
加锁的基本单位是临键锁
创建索引会使查询操作边得更快,但会降低增删改的速度,因为执行这些操作的同时会对索引文件重新排序或更新
缺点
- 事务问题,不能用本地事务,需要分布式事务;
- 跨节点 Join 的问题:解决这一问题可任意分两次查询实现;
- 跨节点的 count、order by、group by 以及聚合函数问题:分别在各个节点上得到结果后在应用程序端进行合并;
- ID 问题:数据库被切分后,不能再依赖数据库滋生的主键生成机制,最简单可以考虑 UUID;
- 跨分片的排序分页问题(后台加大 pagesize 处理)
单表行数超过500万行或者单表容量超过2GB,才推荐进行分库分表。
阻塞 IO 模型(BIO)
非阻塞 IO 模型(NIO)
IO 多路复用模型
信号驱动模型
首先开启套接口信号驱动IO功能,并通过系统调用 sigaction 执行一个信号处理函数,此时请求即刻返回,当数据准备就绪时,就生成对应进程的 SIGIO 信号,通过信号回调通知应用线程调用 recvfrom 来读取数据
异步 IO(AIO)
异步 IO 的优化思路解决了应用程序需要先发送询问请求、再发送接收数据两阶段模式,只需向内核发送一次请求就可以完成状态询问和数据拷贝的所有操作。
select:监听的 IO 最大连接数有限,Linux 一般是 1024,可以调大;需要遍历 fdset,找到就绪的 fd
poll:poll解决了连接数的限制
epoll:采用事件驱动,epoll 先通过 epoll_ctl() 来注册一个 fd(文件描述符)。一旦某个 fd 就绪时,内核会采用回调机制,迅速激活这个 fd,当进程掉 epoll_wait() 时便得到通知,采用监听事件回调机制。
read+write
方式会产生2次DMA拷贝+2次CPU拷贝,同时有4次上下文切换。mmap+write
方式则产生2次DMA拷贝+1次CPU拷贝,4次上下文切换,通过内存映射减少了一次CPU拷贝,可以减少内存使用,适合大文件的传输。sendfile
方式是新增的一个系统调用函数,产生2次DMA拷贝+1次CPU拷贝,但是只有2次上下文切换。因为只有一次调用,减少了上下文的切换,但是用户空间对IO数据不可见,适用于静态文件服务器。sendfile+DMA gather
方式产生2次DMA拷贝,没有CPU拷贝,而且也只有2次上下文切换。虽然极大地提升了性能,但是需要依赖新的硬件设备支持。雪崩:缓存机器宕机,导致请求全部打到数据库上,打死数据库
解决方案:
- 事前: Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃
- 事中:本地缓存 + 限流&降级(限制最大通过请求,超出的降级),避免数据库被打死
- 事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速回复穿透:大量构造的恶意请求,导致缓存查不到,最终打死数据库
解决方案:在缓存前面增加布隆过滤器,将数据库所有可能的数据哈希到不空过滤器,查缓存的时候先用布隆过滤器筛一遍
- key不存在,则key非法,直接返回
- key存在,再继续查缓存
击穿:热点key再失效的瞬间,大量请求直接请求到数据库,打死数据库
解决方案:
- 数据基本不更新,可以设置该热点数据永不过期
- 数据更新不频繁&缓存刷新耗时比较少,可以用本地锁/分布式锁保证少部分请求过去并更新缓存,其余线程可以在锁释放后访问到新缓存
- 更新频繁&刷新耗时较长,可以用定时线程在缓存过期前主动延后缓存过期时间
类似于 RocketMQ中广播模式,简单易上手
优点:轻量级、低延迟、低可靠性
缺点:
使用需考虑:消费者并发数、生产的速度和消费的速度、是否需保证可靠性
如果要求可靠性 & redis版本>=5.0,可以考虑使用 Redis Stream,如果有更复杂的要求,还是考虑使用专门的消息队列(kafka、RocketMQ、Pulsar等)
一致性 Hash 算法能够在 Hash 输出空间发生变化时,引起最小的改动。应用在像分布式系统扩容缩容的时候。
原理是将整个哈希输出空间设置为一个环形区域,对数据进行 Hash 操作,映射在环形区域上,然后让该值沿顺时针方向移动,遇到的第一个服务器就是它分配的节点。
当服务节点很少的时候,会有很多 key 被分配到同一个服务实例上,称之为“数据倾斜”。如何解决?虚拟节点,即对内阁服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称之为虚拟节点。具体做法可以在服务器 ip 或主机名的后面增加编号来实现。同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射。虚拟节点越多数据月均匀,一般虚拟节点数要在32个以上
一次调度任务只有一个机器执行,不会因为是分布式部署而出现多台机器同时执行某个job。思路是使用分布式锁
Quartz:
acquireTriggersWithinLock=true
获取 trigger 的时候上锁(抢占式获取数据库锁并由抢占成功的节点负责运行),默认是 false,使用乐观锁,但可能出现 ABA 导致重复调度XXL-JOB:使用数据库悲观锁
setAutoCommit(false)
关闭隐式自动提交select lock for update
显式排他锁,其他事务无法进入&无法实现for update
- 读数据库任务信息 -> 拉任务到内存时间轮 -> 更新数据库任务信息
commit
提交事务,同时释放for update
排他锁(悲观锁)
性能高,但redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
可靠性高,但性能不如redis分布式锁
集中解决方案:
TCC 优点:可以控制数据库操作的粒度,降低了锁冲突,可以提升性能;
TCC 缺点:应用侵入性强,需要根据网络、系统故障等不同失败原因实现不同的回滚策略,实现难度大,一般借助 TCC 开源框架,例如 ByteTCC、TCC-transaction、Himly。
二阶段消息:参考 DTM 框架
核心思想:通过唯一的业务单号保证幂等性,非并发情况下,查询业务单号有没有操作过,没有则执行操作;并发情况下,此过程需加锁。
根据唯一业务号取更新数据:通过版本号来控制。用户查询出要修改的数据,系统将数据返给页面,将数据版本号放入隐藏域,用户修改数据提交时将版本号一同提交,后台使用版本号作为更新条件
update set version=version+1, xxx=${xxx} where id=xxx and version=${version};
没有唯一业务号的 update 与 insert 操作:进入到注册页时,后台统一生成 Token,返回前台隐藏域中,用户在页面提交时将 Token 一同传入后台,使用 Token 获取分布式锁,完成 insert 操作,执行成功后不释放锁,等待过期自动释放。
参考 Java问题排查
JSR-356 规范,即 Javax WebSocket,定义了 Java 针对 WebSocket 的 API,主流的 Web 容器都已经提供了 JSR-356 的实现,例如说 Tomcat、Jetty、Undertow 等等。
目前提供 WebSocket 服务的项目中一般有几种方案:
一般非 IM 即时通讯项目,使用前两种都可以。具体参考:
为何会断线?
如何判断在线、离线?
如何解决断线问题?
客户端心跳检测:
1. 客户端定时通过管道发送心跳,发送时启动一个超时定时器
2. 服务端接收到心跳包,应答一个pong
3. 如果客户端收到服务端的应答包,则说明服务端正常,删除超时定时器
4. 如果客户端的定时器超时依然没有收到应答包,说明服务端挂了
服务端心跳检测:
1. 客户端定时通过管道发送心跳
2. 服务端记录每个用户最后一次心跳时间,并配置一个心跳最大间隔时长
3. 开启一个定时任务,根据每个用户最后一次心跳间隔时间和最大间隔时长来判断用户是否断线
4. 遇到超过最大间隔时长的直接剔除会话
基于滑动窗口 ACK 。整体流程如下:
这种方式,在业务被称为推拉结合的方案。此种方案客户端和服务端不一定需要使用长连接,也可以使用长轮询所替代。客户端发送带有消息版本号的 HTTP 请求到服务端。
事故情况:由于redis内存报警,导致接口失败率上升(没有配置redis拒绝策略,接口阻塞)。询问发现是上游同事进行了发布,于是迅速回滚,同时增大redis内存。
措施:让上游服务回滚;对redis扩容增大内存
事故原因:上游服务的对账系统调用自己的接口
1. 没有考虑到对账数据范围(进行了全量对账,存在冷数据)
2. 在业务高峰时期对账
3. 自己redis没有设置合理的淘汰策略
事故情况:MQ消费者服务OOM,致使服务直接挂掉
措施:重启服务,先暂时恢复
事故定位:查看 prometheus 上的服务监控,从某时刻开始内存明显飙升 && dump内存快照
jmap -dump:live,format=b,file=/tmp/dump.hprof PID
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/app/oom
XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file));
XSSFSheet sheet = wb.getSheetAt(0);
解决:改用 SXSSFWorkbook
XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file));
SXSSFWorkbook swb = new SXSSFWorkbook(wb,100);//使用SXSSFWorkbook包装,一次读入内存100条记录
SXSSFSheet sheet = (SXSSFSheet) swb.createSheet("sheet1");
//省略中间代码
sheet.flushRows();
后续思考:使用 EasyExcel。MQ异步导入excel,并发量大的话仍然会出现OOM,有安全隐患,调整mq消费者线程池,设置成4个线程,避免消费者同时处理过多消息、
事故情况:有个服务内存使用率不断飙高,主要逻辑就是消费MQ的数据然后持久化
措施:重启应用,但无果
事故定位:查看监控数据发现老年代内存发生 GC 也一直居高不下,查看jstat日志发现老年代内存回收不了。尝
试在本地复现。
com.lmax.disruptor.RingBuffer
对象内存占用很大1024*1024
,目测是这的问题解决:调小 Disruptor 的 RingBuffer 配置,调整到多少得根据业务具体情况配置
事故情况:生产某个服务版本升级的时候,实例拉起后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导出功能如何实现?
导入功能比导出麻烦点,需要各种校验,留出接口方便业务校验扩展
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
环形队列做异步处理,尽可能让消息推送接口更快返回。如果并发量比较高,可以加入消息队列,路由服务直接消费消息,以此来提升服务整体性能。