你问我答05
- 问:,先来写道题吧,用栈来实现一下队列的效果吧。嗯,简单说一下你实现的思路吧。
我是用了两个栈实现的,一个是来模拟队尾,一个是用来模拟队头。入队的时候呢直接进队尾的那个栈就行。然后要出队的那个时候就先查队头的这个栈啊,如果这个栈有数据的话,嗯,就需要直接弹出啊。如果说是没有的话,嗯,就把队尾的那个栈里面的全部数据都弹出来,然后压到队头的栈,等这个队尾的栈弹干净了之后,再从队头栈里面呃数据出栈就可以了。
- 问:平时用过零拷贝技术吗?说说他为什么比普通的拷贝要快呢?
比如说我把磁盘的数据发送出去,要是按照正常的方式,就需要创建一个byte数组。然后调read 方法读取数据,到这个byte数组里面,这就发生了一次用户态到内核态啊,还有一次内核态到用户态的切换。然后我们的线程再把这个byte数组拷贝到网卡的缓冲区里面啊,这又是两次用户态,还有内核态之间的转化。这种切换其实就是为了进行数据拷贝,其他的啥也没干,就是比较耗资源了。那这个零拷贝呢他就可以直接把磁盘的数据拷贝到网卡缓冲区里面啊。就这样的话就是减少了这些用户态和内核态的切换啊,也减少了数据在缓冲区之间的拷贝了。
- 问:嗯,你实际用过零拷贝在哪?
嗯,没有哎,但是我知道Kafka里面使用零拷贝的场景。 要不我展开说说这个可以好。Kafka中broker 底层存储消息的时候,会同时创建一个稀疏索引。Kafka会对这个稀疏索引进行内存映射,实际上就是调用了mmap 系统调用,拿到一个Buffer对象,这个buffer 实际上就是一个direct buffer。这样的话呢我们就可以直接写这个Buffer对象,就可以把这个数据写到磁盘缓存里面了,然后操作系统就会找机会刷磁盘,然后再就是consumer 来拉取消息的时候,Kafka直接调transfer to 方法,把log 文件的数据写入到了socket 缓冲区里面。这里面实际上用的就是send file 系统调用啊,这也就是一个零拷贝。
- 问:那使用Direct Buffer 有什么注意的点?
Direct Buffer, 因为是堆外内存不会被正常的GC回收。只有在Full GC的时候才会触发回收。我一般会明确指定堆外内存的大小,具体的JVM参数我有点忘记了,好像是MaxDirectMemory 这个参数。那这样的话就不会被系统kill。嗯,Direct Buffer 创建和销毁会比普通的堆内buffer 开销大,所以长时间反复使用的内存区域我才会考虑使用Direct Buffer,我们也可以像那个netty 那样对Direct Buffer 进行池化。如果确实是需要释放direct buffer 的话,这个地方还会跟java 版本耦合。比如说我们在java9之后呢,需要调用的是unsafe 类里面的方法啊进行释放。Java9之前呢需要调的是Cleaner 方法,总之都是需要通过反射去调用的。嗯,反正不是很建议这么搞。
- 问:除了Direct Buffer,你还在其他地方使用过池化的技术吗?
呃,比较常用的是连接池,比如数据库的连接池。各种Cliect里面网络连接池。
- 问:了解过这些连接池的底层实现吗?
大概了解过连接池的原理,就是因为连接的创建还有断开都是比较消耗资源的。毕竟这是一个建连的过程,需要进行握手,鉴权这样的一堆操作。而且建连之后,TCP有一个拥塞窗口的概念,也就是刚建连的时候它会比较小。然后TCP的传输能力也比较差。随着这个数据不断传输,这个窗口才会逐渐变大。虽然有些优化参数可以调整这个窗口,但是总是没有复用连接的这个效果更好。连接池里面一般是单个的connection,不是线程安全的。然后整个ConnectionPool是线程安全的呃,connection 对象底层其实是一个socket 连接,还有关联的输入输出流。然后connection 还会实现close 接口。嗯在这个close 方法里面,不是直接关闭这个socket 的连接,而是把这个connection 对象放回到线程池里面,然后其他的线程就可以从这个线程池里面再拿到这个connection 来使用。然后在整个使用过程里面,这个connection 对象就会被业务线程独占,然后用完之后再调close 方法返回线程池。,一般情况下,连接池是单例的。比如我们连接一个redis 集群,一个服务,只需要创建一个线程池就可以了,然后复用里面的连接就可以。然后在创建连接池的时候,要指定它的最大连接数,还有最小连接数,还有连接过期时间。然后在没有请求的时候,空闲连接会超时释放,然后只保留最小连接数。这些连接通过心跳保持,这就主要就是为了防止突然有大量请求过来,然后创建连接比较慢的这个问题。然后在这个请求越来越多的时候就需要不断的新建连接。然后新建数会涨到我们指定的最大值啊,就会把这个连接池打满啊,在这之后连接就不会再创建了。然后新的请求就要排队等待空闲连接啊,这就可以防止连接疯狂创建,然后导致内存被打满的这种情况。要是一个新服务上线,我一般会做一个简单的压测啊,来确定一下这个连接数的上下限。而且还会把连接池的配置设计成这个动态配置,方便我们上线之后的调整啊,这就要结合阿波罗之类的配置中心实现了。
- 问:你用过Dubbo吗,了解底层的实现吗?
嗯,这个我有简单了解过。
- 问:那如果让你实现一个RPC框架的话,大概说说实现的思路吧。
如果是自己实现RPC的话,我可能会借鉴Dubbo 还有GRPC这些成熟的RPC框架,考虑给整个项目分层,我分层的主要目的呢就是为了解耦。尽可能做到层与层之间通过接口交互,这样就解偶了,你懂吧?最底层的话是序列化层,这一层呢就包含了各种的序列化算法。序列化就是把java 对象转化成byte数组。然后反序列化的话就是把byte数组转换成java 对象。这个的话我们可以根据自己场景,然后切换不同的序列化算法。现在基本上都会用pb了啊,因为pb的性能要比hanson 之类的要强很多。再往上呢就是网络层。网络层的话基本上就是建连啊,还有维护连接池,处理读写请求之类的这样的功能。现在基本都是用netty 这种比较成熟的Nio框架做了,很少有人会直接裸写Nio的代码就很容易出问题。然后再往上的话,就是协议层。协议的话就是规定了我们client 和server 是怎么交互的。比如Dubbo 的话,他有自己的RPC协议。比如说消息投消息体。然后消息头里面就包含了一些整个消息的控制位和整个消息的长度。然后消息体的话就是一些有效的负载信息。也可以选择现成的Http协议来做。再往上的话就是一些集群相关的事情了。在线上的话server 肯定不止一台实例。所以说我们要根据请求还有路由配置确定一个请求要发到哪个server 上。那就需要一个注册中心模块来让这个client 感知到是有哪些server 实例。然后再通过负载均衡器路由算法之类的实现。然后把请求路由到指定的server 端进行处理。可能还要有一个配置模块。这个配置模块主要就是配置RPC框架的一些特性,还有开关。你比如说连接池的上下线,路由算法,还有负载均衡算法啊,server 的黑白名单啊,都是可以通过这个配置模块进行动态配置的。最上层的话就还要搞一个代理层,这一层可以用JDK动态代理,或者是其他字节码生成的方式,屏蔽RPC底层的这些网络调用啊,负载均衡的复杂操作。这样的话我们就可以像调用本地方法一样进行RPC调用了。要是要是再完善一下的话,还可以添加一些服务治理,还有熔断之类的能力。
- 问:了解过ThreadLocal 这个工具类吧。
嗯,这个有了解过,ThreadLocal 是用来存储线程绑定的数据。他在一个线程里面维护了一个ThreadLocalMap集合,这个map的key就是ThreadLocal对象。然后value 呢就是线程里面和这个ThreadLocal对象绑定的值。
- 问:嗯,踩过什么ThreadLocal 的坑吗?
嗯,坑倒是没踩过,但是使用ThreadLocal 还是有很多注意事项的。比如说在一个线程池里面使用ThreadLocal 的话啊,因为线程池里面的线程都是复用的。比如tomcat 处理请求的线程池,当一个线程处理完请求之后啊,他在开始处理下一个请求之前,我们需要把上一个请求设置的ThreadLocal全部清掉。要不然的话这个线程池里面的所有ThreadLocal 就都乱了。再一个就是ThreadLocalMap这个集合里面。存储KV的时候,那个k 存的是一个弱引用。弱引用的话就可能会被GC回收掉啊,但是现在的value 呢是强引用不会被回收。所以ThreadLocalMap也对这种情况做了一些防范,就是在下次进行读写的时候,扫描到k已经被回收的元素,就主动把它value 值的引用也断开,value 就能被回收了。所以说最好还是手动明确的清理一下。依赖这种自动的机制不知道啥时候才能被回收。
- 问:了解过ConcurrentHashMap 吗?
用过,那我简单说说里面的实现。
- 嗯,不用了。那我们直接看一个实际问题吧。我我现在有一万个KV数据,每分钟增加十个,有几个线程随机抽取k 进行累加啊,具体累加的值从另一个集合里边随机抽取啊,初始化是十之后,每五分钟增加十个。那这段代码怎么写呢?嗯,说一下实践思路吧。
嗯好的,存KV使用的是ConcurrentHashMap。嗯,ConcurrentHashMap是可以保证呃读写是原子性的啊。嗯,所以用这个ConcurrentHashMap存储k v 呃,然后随机抽取的话,就没有现成安全的问题了。然后每分钟增加的这十个KV的话,也就是直接放在那个ConcurrentHashMap就行。这也是线上安全的。然后随机抽取的这个累加值,这个是放到CopyOnWriteArrayList啊,因为抽累加值的这个操作是个明显的读多写少的场景嘛。所以说我们用CopyOnWriteArrayList会比较合适。
- 问:那我加一个条件啊,如果我们现在的这些数据有个长度限制,增加到两万的时候,就不能再添加了。嗯,那这个时候使用ConcurrentHashMap这个结构还可以吗?
嗯,只用ConcurrentHashMap的话肯定是不行了,因为判断到没到两万是个复合操作需要加锁。而且ConcurrentHashMap的这个size 拿到的这个长度也没法保证是线程安全的。他拿到的可能是一个中间态的值。可以考虑在ConcurrentHashMap外层加锁。还有一个方式就是加一个AtomicLong。这样的话嗯AtomicLong来保证技术的线程安全,性能会比直接加锁要好很多。
- 问:你在工作里面使用的Jdk版本是多少?
1.8
- 问:那你关注过computeIfAbsent()可能导致死锁的问题吗?
嗯,他是这样的,死锁是在computeIfAbsent()方法嵌套的时候才会出现。而且两个嵌套的computeIfAbsent()的方法,操作的key 要打到同一个Hash桶上才行。这个漏洞好像已经修了,我直接升级Jdk版本就行了。再具体的原因我就没太关注了。
- 问:如果你发现你的服务变慢了,有什么排查的思路吗?
首先我会看一下监控确定慢的具体描述是什么,具体是哪个服务,哪个接口响应慢了。然后通过上下游耗时的监控确定一下是服务自身的问题呢还是上下游服务的问题。比如说上游出现GC了,或者是下游服务耗时高了都会让我们有这种服务慢了的感觉。还要确定一下外部条件是不是发生了变更。比如上下游的放量,自身配置的变更,还有自身的上下线操作,还有容器的迁移之类的。要是时间点吻合的话,大概率就是相关操作导致的就可以考虑先回滚止损了。那要是上面我描述这些问题确定都没有的话,那就要考虑我们服务自身的问题了。我一般会先去看一下整个服务的面板啊,服务所有的实例都慢了,还是单台机器拉低了整个服务的性能。如果是单机的话啊。可以考虑先下掉这个实例,然后再观察一下。那那要是恢复了,可能是这个实例在的这个机器的问题啊,要是又出现了另一个实例,性能变差啊,就考虑是某一个特殊的操作拉低了我们服务的性能。你比如说任务调度的场景,这个任务会被调度到哪个实例上,然后哪个实例就会变慢啊,那这台变慢的这个实例就是我们的目标了。要是这个服务全部实例都变慢了。那我也去找一台实例作为目标,然后进行具体的分析。我们确定目标实例之后呢,我就会根据具体的业务场景去关注他的这个CPU,还有内存,还有IO这三个方面啊。比如说这个服务是计算密集型的啊,我就会去关注CPU啊,比如说我们可以用top,还有jstack,还有Arthas,然后看看占用CPU比较高的线程,还有各个线程的状态。然后再结合业务逻辑分析一下。比如都是block 状态,那就看看是不是有竞争锁啊,然后死锁啊这样的问题,然后尝试降低锁的力度,然后还有锁的持有时间啊,无锁化处理这些之类的,然后来提高并发度。要是CPU太高了啊,就可能是算法写错了啊,然后写了死循环或者是循环写多了之类的,那就要考虑优化算法或者是数据结构。还有一种可能就呃线程上下文切换的问题啊,那我们可以考虑用那个pidstat命令看一下线程上下文它切换的这个频率。然后就是JVM内存的调整,一般会先去关注这些的问题。先用jstat,gcutil命令,然后看看是否频繁的Full GC啊。如果有的话,就可以进行一次dump ,然后用那个MAT工具看一下内存里面最多的对象啊,基本就可以判断出问题了。那如果是没有办法用dump,那就可以用jmap命令直接看。要是代码没有优化空间了,我们就可以考虑架构上还有调优上的解法啊。比如说多部署几个小内存的实例啊,还有实时性要求没有那么高的啊。然后可以前面加层Kafka进行消峰之类的。那如果是IO的问题的话。可以先用pidstat看看每一个进程的读写情况。然后加一个-t参数,看看是不是哪个线程读写有问题。然后然后我们再结合这个代码分析一下,java 服务上就很少会见到磁盘IO的问题。除非说是日志打多了,然后网络IO的话,java 现在基本上都是netty 这种Nio框架实现了啊,也就是调整一下线程池的线程数啊,Buffer池的大小,这些操作还要再拉一下SRE看一下啊,看是不是系统配置上的问题。比如TCP缓冲区给小了,或者是连接数上限给小了之类的这样的问题。
- 问:要是发现服务慢是GC的问题啊,但是现在内存啊又没办法扩容,那你该怎么办呢?
其实单纯进行扩容不一定能够搞定那个这些导致的卡顿问题。我一般会打开GC日志的开关啊,然后用一些GC日志分析工具,然后来看看怎么搞。有些配置是可以直接调整的。比如启动的时候让JVM内存直接开到最大,而触发这些的预值之类的。但是有些就需要根据这些日志然后进行检查了。比如让GC这些非常频繁可能是生命周期短的对象非常多。这个时候我们就可以适当增大年轻代的大小。再比如老年代迅速被占满了,可能是年轻代给的太小了啊,全部通过担保或者是连续的YoungGC进入了老年代啊,这个时候就还是应该尝试扩大年轻代。也可能是Survivor 去了。也可以根据实际情况调整这些的并发线程数,还有触发这些的阈值。这些都是我们可以调优的点。
- 问:我们再来聊个数据库方面的问题吧。我现在有一个订单生成的需求,希望做到全局趋势递增啊,你有什么实现思路吗?
嗯,趋势递增啊,那那个uuid肯定是不行了是吧?那使用mysql 自增id 可以吗?
- 问:可以是可以,要是订单号生成的QPS涨到千万级别的话,甚至更高啊,那你怎么办呢?
千万级QPS啊,那意思就是说使用mysql 自增ID是撑不住,对吧?那要不然拆表拆库,这样行不行呢?
- 问:这不太行啊,就算一个mysql 实力支撑一万QPS,那你需要万级别的mysql 来支撑这个订单号生成的业务啊成本太高了。而且趋势递增也没有实现。
嗯,我想想。那要不用雪花数吧。就是对一个Long进行切分。比如说高位用来标识当前的时间戳,可以是距离2022.03.1.0的一个时间偏移量就行了。然后如果是有多机房的话,可以再加一个机房的标识,空个两三位就行。再然后的话呃就是这个机房内的机器标识啊,这个根据订单号服务的机器数确认啊,基本留个十位就差不多了啊。最后就是在时间戳里面的自增i d。这样的话就实现趋势递增了啊,也不用存储。
- 问:那要是我们在秒级内要生成的订单数超过了这个自动id的范围,那怎么办呢?
那可以考虑使用数据库加那个本地缓存的方式来实现。那个在表里面存id,start_id还有step 三列,然后id就是生成器的唯一标识。然后start_id就是这个id生成器以及生成的最大id值。然后在这个订单服务需要生成一个订单号的时候,会先通过RPC请求到我们的id生成器的一个实例。这个实例就会把start_id增加一万。比如start_id初始化是零。那第一次请求就会把start_id更新成一万啊,然后这个id生成器啊就会缓存零到一万这些id。然后从零开始返回这些id。等这一万个i d 都被取走了之后啊,这个id生成器就会再去Mysql去那里取下一个范围的i d,然后缓存在本地。然后我们线上的id生成器就会部署多个实例进行负载均衡。可以使用mysql 乐观锁的方式,保证每个范围的id只分配给一个实例缓存。还可以在本地多加几个范围缓存,在耗尽一个范围的id的时候,我们就异步去数据库取数啊,这样就可以防止耗尽了才去同步请求数据库啊,这就可以防止抖动。那缺点呢就是在id生成器上下线的时候会导致缓存的id范围丢失啊,浪费一些id感觉问题也不大啊。要是觉得浪费的比较多啊,那就可以调整每次取的id范围啊,比如就就别每次取一万了啊,可以改成每次取一百这样。




