二面的面试官来到来我的跟前,开始对我的简历进行了一番打量然后就开始了技术提问。
面试官: 看了下你在简历上边有写到过关于电商系统的设计,那我想深入问下你在电商
系统设计的几个问题哈。
小林: 好的。
面试官: 你们电商系统的每天的日活量大概在多少呢?
小林: 嗯,日活用户数目在5万左右,搞促销活动的时候还会涉及到一些大流量的访问。
面试官: 嗯嗯,那么接下来我问你几个系统内部设计的场景吧。
小林: 嗯嗯。(表面风平浪静,内心还是会有些慌张)
**面试官:**你刚才提到了促销活动,那么在搞促销活动之前,你们应该会有一些特殊的准备吧,能和我讲几个场景的实际案例吗?
小林: 嗯嗯,我们的商品信息其实是存储在mysql里面的,当进行促销活动的时候需要进行一次预热工作,将一些热点数据加载到缓存层当中,减少对于实际mysql访问的压力。在缓存方面我之前一贯都是使用了redis来存储数据,但是高峰时期对于redis的查询依然是需要网络消耗,有些特殊的业务场景需要在循环里面对redis做查询(老旧代码的原因,不推荐在工作中这么使用),因此这部分的模块我加入了本地缓存作为优化手段。
面试官: 嗯嗯(就这??)
小林停顿了一会,看面试官似乎还觉得说得不够,然后继续回答接下来的内容点。
小林: 对于一些热点数据而言,我们的本地缓存使用的是Guava Cache 技术,它提供了一个LoadingCache接口供开发者进行缓存数据的本地管理。当查询数据不存在的时候会发生缓存命中失效,这时候可以通过定义内部的一些callable接口来实现对应的策略。
ps: 此时小林想起来自己以前刚学习guava cache技术时接触的代码:
//这种类型到好处在于 查询数据的时候,如果数据不存在,那么就需要写如何从内存里加载,每次查询都需要做一个callable的处理 Cache<Object, Object> cache = CacheBuilder.newBuilder().build(); cache.put("k1","v1"); //如果对象数据不存在,则返回一个null值 Object v1=cache.getIfPresent("k1"); Object v2 = cache.get("k2",new Callable<Object>(){ @Override public Object call() throws Exception { System.out.println("该数值不存在,需要到redis去查询"); return "k2"; } }); System.out.println(v1); System.out.println(v2);
面试官:如果每次查询不了数据都需要在get的时候去重写策略,岂不是很麻烦吗?(其实面试官也用过这款技术,就是故意深入问问求职者是否有更多的了解内部构造)
小林:嗯嗯,其实可以在定义LoadingCache做一个全局性的callable回调操作处理,我脑海中还对这段代码有印象,主要是通过cacheloader来做实现。
ps:此时一段熟悉的代码模型从小林脑海中闪过。
LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder() .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { // 当缓存没有命中的时候,可以通过这里面的策略去加载数据信息 System.out.println("缓存命中失败,需要查询redis"); return "value"+key; } });
面试官: 嗯嗯,那你对于这些缓存算法有过相关研究吗?可以讲讲自己的理解吗?
小林: 嗯呢,常见的缓存队列可以采用lru算法,所谓的lru其实本质的核心就在于:最近最久未使用的数据,可能在将来也不会再次使用,因此当缓存空间满了之后就可以将其淘汰掉。简单的实现思路可以用一条队列来实现,当数组中的某个元素存在且再次被访问的时候就会将其挪到链表的首位,如果查询某些新元素发现在lru队列里面没有命中,则需要从db中查询然后插入到队列的首部。这样能够保持队列里面的元素大多数场景下都是热点元素,当队列的体积占满了之后,访问最低频率的元素就会从队尾被挤出。
面试官: 嗯嗯,可以再深入讲解下lru算法所存在的弊端吗?(内心仿佛在说,就这?)
小林: 嗯嗯,lru算法其实存在这缓存污染的问题,例如说某次批量查询操作,将一些平时用不到的数据也塞入到了队列中,将一些真正的热点数据给挤出了队列,造成缓存污染现象。因此后边就衍生出来了lru-k算法,其核心思想是给每个访问的元素都标识一个访问次数k值,这类算法需要多维护一条队列(暂且称之为访问队列),当数据的访问次数超过了k次之后,才会从原先的访问队列中转移到真正的lru队列里面。这样就能避免之前所说的缓存污染问题了,但是采用lru-k算法其实需要涉及到的算法复杂度,空间大小远高于前边提到的lru算法,这也是它的一个小”缺陷“吧。
面试官: 嗯嗯,好的,那关于缓存的回收策略你有了解过吗?
小林: 嗯嗯,我在之前的工程中使用的guava-cache技术就是采用惰性回收策略的,当缓存的数据到达过期时间的时候不会去主动回收内存空间,而是在当程序有需要主动查询数据的时候才会去做内存回收的检测等相关操作。之所以不做主动回收的工作,我推测是因为自动回收程序的步骤对于cache自身需要维护的难度较高,所以改成了惰性回收策略,这一点和redis里的惰性回收策略有点类似,这种策略容易造成某些长期不使用的数据一直没法回收,占用了大量的内存空间。
面试官: 嗯嗯,好的,那么这个面试点先到此告一段落吧,我再问下你其他的业务场景。
小林内心渐渐恢复平静,一开始的那种焦虑和紧张感渐渐地消失了,又恢复了从前的那种淡定和从容。
小林: 好的。
面试官: 你们的订单业务系统一般是怎么做分表操作的啊?可以介绍一下吗?
小林:
嗯嗯,可以的,我们的订单表每日的增加数目为5万条数据左右,一个月左右订单的数据量就会增加到100万条左右的数据,因此我们通常每个月都会按照月为单位来做分表操作。在用户的电商app界面上边有个订单查询模块,关于那块的业务我也做过相关的开发。
通常我的订单查询的数据都是按照时间顺序,先查询最近的数据,再查询之前的数据信息,结合前端做了分页涉及的功能,会根据前端传入的月份时间,来识别应该定位在哪张表进行查询。通常来说近三个月时间内的订单数据都是一些热点数据,所以我们将前三个月的数据存在同一张表里面专门做优化。
关于后续几个月的数据大多数情况下用户自身并不会涉及到查询功能,因此我们会定时将数据同步到es数据库里面,如果后续需要涉及这块的数据查询,则走es数据库。
关于订单数据如何定时同步到到es这块,相关的查询逻辑图如下所示:
这里面会有一个job去维护MySQL和ES之间的数据一致性。
面试官: 嗯嗯,那么你们的es和mysql之间是怎么做数据一致性的维护呢?
小林: 我们借助的是阿里的一款开源中间件做数据同步,结合了canal+mysql+rocketmq来进行实现的。
canal会模拟成一台mysql的slave去接收mysql的master节点返回的binlog信息,然后将这些binlog数据解析成一个json字符串,再投递到mq当中。在rocketmq的接收端会做消息的监听,一旦有接收到消息就会写入到es中。
面试官: 嗯嗯,那么你能简单讲解下在这过程中遇到的困难吗?
小林: 额,其实这一套环境在我入职的时候就已经搭建好来,我也只是大概知道有这么一个东西,具体的很多细节也并不是很熟悉....
(此时小林的内心再一次流下了没有技术的眼泪.......)
面试官似乎有点失望,看了下项目,于是便切换了另一个问题进行询问。
ps:
当我们使用canal进行binlog监听的初始化时候,难免需要遇到一些全量同步和增量同步的问题,那么在这个先全量同步再转换为增量同步的过渡期间该如何做到程序的自动化衔接呢?
关于这块的设计方案可以参考下mysql内部是如何重建索引树的思想。
在mysql进行索引树重建的时候,会将原先表的所有数据都拷贝存入另外一张表,在拷贝的期间如果有新数据写入表的话,会建立一份redo log文件将新写入的数据存放进去,保证整个过程都是online的,因此这也被称为Online DDL,redo log在这整个过程中就起到了一个类似缓冲池的角色。
同理在使用canal做日志订阅的时候也可以借助这么一个“缓冲池”角色的帮助。这个缓冲池可以是一些分布式缓存,用于临时接收数据,当全量同步完成之后,进入一个加锁的状态,此时将缓存中的数据也一同刷入到db中,最后释放锁。由于将redis中的数据刷入到磁盘中是个非常迅速的瞬间,因此整个过程可以看作为几乎平滑无感知。
那么你可能也会有所疑惑,mysql表本身已经有初始化数据了,该如何全量将binlog都发送给到canal呢?其实方法有很多种,binlog的产生主要是依靠数据发生变动导致的,假设我们需要同步的表里面包含了update_time字段的话,这里只需要更新下全表的update_time字段为原先值+1 就可以产生出全表的binlog信息了。
整体的设计思路图如下:
(可惜小林平时在工作中没有对这块做过多的梳理)
面试官:好吧,你在平时的工作中有遇到过一些jvm调优相关的内容吗?
小林:嗯嗯,有的。
面试官:哦,太好了,可以讲讲你是怎么去做jvm调优的吗?
小林:我们一般都搞不定,遇到jvm栈溢出的时候重启并且增加机器就完事了。
面试官:...... 这确实是一种方案,能不能讲些有价值点的思路呢?
小林:嗯嗯,我之前有学习了解到过,Java虚拟机将堆内存划分为新生代、老年代和永久代。
l通常来讲,我们代年轻代对象会被存放在eden区域里面,这块区域当内存占比会相对于survivor区要大一些,大部分当对象在经历一次Minor GC之后都被销毁了,剩余当对象会被分入到survivor区里面,然后在survivor区进入新的垃圾回收,当回收当次数超过了阈值之后(默认是15次),对象就会从年轻代中晋升到老年代。当然如果说survivor区中相同年龄的对象体积大小之和大于了survivor区中一半的空间,那么此时对象也会直接晋升到老年代中。哦对了,jdk8
之后还多出来了一个叫做元空间的概念。
小林非常流畅地将自己对于jvm的理解讲了出来,感觉自己的这番回答似乎很满意。可是别小瞧对方面试官,人家毕竟是有过十多年经验的大佬啊。
面试官:嗯嗯,能深入介绍下吗你对于垃圾收集器使用方面的一些经验总结吗?
小林:额....这块就不是很熟系了
ps:
对于 JVM 调优来说,主要是 JVM 垃圾收集的优化,一般来说是因为有问题才需要优化,所以对于 JVM GC 来说,如果当我们发现线上的gc回收频率变得频繁之后,就是需要进行jvm调优的时候了。而对于jvm的垃圾回收而言,应该是针对不同的垃圾收集器来做优化调整。
例如说cms垃圾收集器,这块收集器主要是将jvm分为了年轻代,老年代。在年轻代采用了复制整理算法,在老年代使用的是标记清除算法,再其进行标记对象的时候会发生stw的情况。而在jdk9之后,可以看出一定的趋势,G1回收算法开始在渐渐占领位置,由于以前的分区将jvm的各个模块eden,survivor区都划分地过大了,因此G1将jvm的区域划分为了多个零散的region,将原先连续固定的eden区和survivor区给拆解开来分割成多个小模块,这样一来垃圾回收的停顿时长就会大大降低,减少stw机制造成的影响。
对于 CMS 收集器来说,最重要的是合理地设置年轻代和年老代的大小。年轻代太小的话,会导致频繁的 Minor GC,并且很有可能存活期短的对象也不能被回收,GC 的效率就不高。
对于 G1 收集器来说,不是太推荐直接设置年轻代的大小,这一点跟 CMS 收集器不一样,这是因为 G1 收集器会根据算法动态决定年轻代和年老代的大小。因此对于 G1 收集器,我们更加需要关心的是 Java 堆的总大小(-Xmx)。
面试官: 好吧,那今天的面试就先这样告一段落吧。
小林: 嗯嗯,我还有机会吗...
面试官: 我觉得你后边可以进步和提升的空间还有很大(意思是你太菜了),可以再学习学习哈。
听完此话后,小林留下了没有技术的泪水....,唉,看来这个工作还是得继续找啊....
后来,小林问了下以前大学同学,打探到了新的内推岗位....