上回说到程序员小扎在通过二面之后,最终来到了技术的最后一面。
最后一面当然是我们接下来要讲的这位技术总监,这位技术总监安静的看着程序员小扎的简历,然后说到:我看你的之前的工作做过支付相关的业务,那我问你如果收到了两次微信或者支付宝的回调通知该怎么办?
「小扎」:哦哦,这个我们一般会做幂等,支付回调一般会有订单ID这样的信息,我们只要判断同样的订单ID是否已经处理过,如果处理过了,就直接忽略就好了。
「总监」:那如果在支付过程中,商品库存减成功了,用户的订单更新失败了怎么办?
「小扎」:这个我们一般会用事务来保证数据的一致性。
「总监」:这么说你们用的是MySQL数据库了吧。
「小扎」:是的。
「总监」:那你们的MySQL用的主从模式吗?
「小扎」:是的,一主多从模式,主提供写,从提供读。
「总监」:这里我有个问题,如果下订单的过程中,需要先去查询订单情况,这时候如果读的是从库,因为延时问题,会不会读不到最新的数据,导致数据出现问题?
「小扎」:如果要考虑延时问题,那么一般在对数据实时性要求比较高的场景中,我们会直接读主库,避免因为从库延时问题而读到老数据。
「总监」:我看有的架构让其中的一个从节点默认延迟一个小时,你觉得这样的好处是什么呢?
「小扎」:这个我懂,如果某天不小心删除或者更新了什么数据,然后后悔了,这时候我们可以在一小时内通过这个延迟复制的从节点找到被误更新或者删除的数据。
「总监」:那主从模式是依赖什么日志来复制的?
「小扎」:binlog,正常情况下开启binlog后,变更的sql会发给从节点,每个从节点根据binlog的内容来执行同样的sql达到数据一致。
「总监」:你说binlog里记录的是变更的sql是吧,那如果我执行了这样的sql:
update user set money=money+0.1 where money=rand()
由于rand会生成一个随机数,那么主库和从库同样执行这条sql大概率是会更新不同的数据的,这样不就会导致主从不一致了?
「小扎」:是的,其实binlog不仅仅支持这种statement模式(sql变更记录),还支持row模式,在row模式下,不再记录变更的sql,而是记录具体“这条数据变更了什么”,这样的话通过row模式,即使像rand()这样的随机数也不会出现主从不一致的情况。
「总监」:row模式是屌,但是row模式好像不太完美,你知道row模式的缺点吗?
「小扎」:知道的,row模式比较耗费存储空间,但是我们可以通过配置mixed模式(混合模式),这样的话,MySQL会根据实际情况判断,比如对于不会引起主从不一致的sql,直接用statement模式,对于会引起主从不一致的sql则会采用row模式,这样既保证了数据的一致性也不会因为全部使用row模式而占用过多存储空间。
「总监」:好的,被你装到了,我们接着回到订单的问题吧,如果某一天老板做活动,打算放1千个ipad mini7,价格是平时的1折,但是据悉有10万人来抢,你如何让你的接口抗的住?
「小扎」:你说的是秒杀吧(还有mini7是什么鬼,还没上市吧,这一看就是冒牌货,还有打1折是什么意思,直接打骨折算了),正常来说,分为前端限流和后端限流,前端的话,比如用户在点了下单按钮之后,让按钮置灰,这样用户第二次就会点击不了,避免疯狂的点击造成过多的无用的请求,后端的话,这样大的请求直接走数据库肯定不行,可以把请求存到redis队列里,redis的性能还是非常强的。
「总监」:嗯,说的还是比较浅的,你说前端把按钮置灰,避免用户二次点击,那如果用户再次刷新页面呢?
「小扎」:刷新页面的话,可以请求服务端,前面对于每个请求不仅仅让其进入队列排队待处理,还可以设置一个hash,hash的时间复杂度是O(1),这样当用户刷新页面请求的时候,可以根据用户订单是否已经在redis的hash表中来告诉当前是否可以继续下单。
「总监」:那还有什么办法可以减少疯狂刷新页面造成的请求吗?
「小扎」:有,对于已经下单的用户,可以存一个标记到浏览器本地的localStorage中,这样下次刷新的时候可以先校验本地的localStorage是否已经有对应的标记,有的话,就不用去服务端请求了,说明当前已经下单正在排队中,对于没有下单还在恶意刷新的用户,前端可以做个拦截,比如就算疯狂刷新,也不是每次都去请求,而是每隔1s请求一次。
「总监」:嗯~,你说服务端用hash来判断用户是否已下单,比如userid=100的用户如果下了单就存hash[100]=1,hash表的速度应该没问题,但是还有其他的更加节省空间的方法吗?
「小扎」:可以用bitmap来存,每个user_id占用一个比特位,非常节省内存。
「总监」:前面你说到每个成功下单的请求进入队列,这有问题吧,一共只有1000个商品,如果有10000个排序的用户,那岂不是9000个用户都抢不到。
「小扎」:(真的是,以前只在一个小公司接过支付,一天一共也就10来单,非要考我秒杀,要不是前公司黄了,我被公司秒杀了,会被你今天问秒杀~),这确实,不过由于是活动,商品数量有限,我们可以提前把总的商品数量set到redis里,这样每来一个请求,对应的总数就减一,当总数等于0的时候,直接告诉用户没有库存了,都不用进入排序的队列,这样在客户端拿到服务端没有库存的信息后,再次设置下localStorage,这样不管你有没有下过单都直接返回,不用去请求服务端,这样甚至可以拦截大量的无用请求。
「总监」:你这看起来还不错,但是好像没法保证数据的一致性呀,比如设置bitmap成功、减库存失败、进订单队列失败或者设置bitmap成功、减库存成功、进订单队列失败。
「小扎」:(你是真的🐶),redis也支持事务,但是不支持回滚,因此需要自己来实现回滚了,比如在减库存失败的时候,我们可以把bitmap还原回去,或者不想还原的话,可以采用补偿的方式来使订单成功,比如出现错误的时候,把错误的订单打到另一个校验补偿队列中,然后校验补偿脚本做对应的补偿措施。
「总监」:你们生产环境redis用的多吗?
「小扎」:还挺多的。
「总监」:所以你们用的都是缓存失效再去数据库读的场景吗?
「小扎」:也不全是,因为我做的是C端的,流量还是比较大的,大概有10WQPS的量级,并且巧了,我这边负责的业务大部分都是读的场景,为了保证能较快的响应,我这边是直接用的redis,并不存在失效去读数据库的场景。
「总监」:哦,那如果缓存失效了,岂不是没数据读了。
「小扎」:脚本定期刷入的,等于某些缓存不会失效,有变更的话,脚本会自动刷入的。
「总监」:牛逼,看来你们挺依赖redis的,你说你们的QPS有10W,如果某天流量突发,你们的服务是不是就有问题了?
「小扎」:突发流量确实,但是我们的服务也并不是只能支撑10WQPS,一般会向上稍微扩大下资源,比如其实可以支撑当前QPS的2倍,这样就算突发流量来了,也不怕。同时我们的接口也支持限流,如果某个接口的流量的超过了预期,多余的请求我们就不处理了,防止把服务打炸。
「总监」:那你们用的什么限流算法?
「小扎」:令牌桶。
「总监」:你能说说令牌桶的原理吗?
「小扎」:(又来,非要问我飞机大炮怎么造的,实际却让我拧螺丝),怎么说呢,其实桶就是允许的流量,当然桶中放入的是令牌,每次要处理请求的时候,必须先要从桶中获得令牌,如果没有获得令牌说明放入令牌的速度大概率是跟不上请求了,这时就会限流。
「总监」:那你知道如何实现吗?
「小扎」:嗯,这个我想一下(用的都是开源的包,平时谁看这些呀~,先随便说个吧),桶的话可以是个队列,然后起一个进程向这个队列的一端不停的放入令牌,另一端就是我们的程序不停的获取令牌,这样是不是就可以了。
「总监」:可以是可以,但是你这个还要单独起个进程来放入token,有点浪费资源,你想一想是否还有其他的办法,可以写出来,伪代码就行。
「小扎」:(还写出来),好的,我先打坐思考下,给我一首歌的时候。
陷入沉思的小扎在头脑中构建清晰的逻辑分析框架:
- 首先桶的容量肯定是我们设置的,假设是m,其次就是每秒该放多少个token,这就是对应的QPS,也是配置的,假设每秒放入n个token,这个n也就是放入速度。
- 面试官说用一个新进程来放入token是浪费资源的,因此肯定不需要起额外的进程来放入,那就是每次取token的时候实时放入,这样就不需要另起进程了,假设一个新请求到时来,这时的请求的时间就是当前时间,这次请求过后,下次请求到来时,那么之前的时间就是上次取token的时间,只要知道当前时间now和上次取token时间lasttime之间的差值,再乘以放入速度,这不就是该放入的token吗。
- 因此按道理,每次请求时先算出此时应该放入多少token,这个token=时间*速度 ,也就是,如果能放入则直接放入,并且再取,如果不能放入,那就不放,直接取,取不到就限流 。
「小扎」:你看这样行不行:
func isLimit() bool {
var (
leftToken //剩余的token
m//桶的容量
)
difftime= now-last //时差
inToken = difftime*n //应该放入的token数
if inToken + leftToken <= m { //小于桶容量
leftToken = inToken + leftToken
} else { //大于桶容量
leftToken = m
}
if leftToken > 0 {
leftToken--//消耗一个
return true
}
return false //没有token
}
「总监」:(竟然写出来了~,可以呀),核心是写出来了,但是需要考虑并发的问题,没加锁。
「小扎」:确实(都说是伪代码了,谁还考虑那么多)。
「总监」:ok,我们继续,限流可以限制住超出预期的流量,那现在考虑这样一个问题,首先流量正常,但是我们依赖的服务方出现了问题,比如依赖的服务方出现500,短时间内没法恢复,这时候如果还是不停地去请求,是不是白白浪费了时间。
「小扎」:可以熔断吧。
「总监」:哦,具体说说看。
「小扎」:熔断就是说,如果依赖方在一定时间内错误的次数达到的熔断的标准,就直接切断服务,不去调用它,而这个标准也是可配的,比如10s内如果出现100%的错误等等。
「总监」:那熔断之后,如果依赖方恢复了,我们还继续熔断着吗?
「小扎」:当然也不会,当触发熔断后,说明依赖方出现了问题,但是我们也不能一直熔断着,我们可以在熔断后的某个时间点内,再放点流量进去试探下,比如熔断后的1分钟后先放1%的流量进去看看,如果这时依赖方好了,那么我们继续放大流量,直至恢复到100%,但是如果依赖方还是没好,那么就继续熔断,等到下次放量嗅探,就这样不停的重试,如果依赖方好了,也不需要人工干预,如果依赖方一直没好,也不会一直无脑请求。
「总监」:那从业务上来讲,熔断期间如果没拿到数据不就影响了用户,有没有什么解决的办法?
「小扎」:有,但是得看业务场景,我们可以配置一些兜底的数据,当熔断后,我们虽然不能通过依赖方拿到想要的数据,但是我们可以返回一开始就是为了这种场景而配置的静态数据,当然兜底得看业务场景,有的业务可能兜不了。
「总监」: 平时工作中有用到消息队列吗?
「小扎」:有,主要就是kafka。
「总监」:你们用kafka主要是为了解决什么问题?
「小扎」:流量削峰,异步任务。
「总监」:那你说说消费者组的好处。
「小扎」:首先一个topic可以有多个分区,然后一个group可以有多个消费者,这些消费者共同组成一个消费者组,消费者组里的消费者可以消费topic不同的分区,从而达到一种负载均衡的作用。
「总监」:kafka中的broker 是干什么的?
「小扎」:broker 是消息的代理,Producers往Brokers里面的指定Topic中写消息,Consumers从Brokers里面拉取指定Topic的消息,然后进行业务处理,broker在中间起到一个代理保存消息的中转站。
「总监」:kafka中的ISR、AR、OSR又代表什么,说说他们之间的关系
「小扎」:AR:所有副本集合,ISR:所有符合选举条件的副本集合,OSR:落后太多或者挂掉的副本集合,AR = ISR + OSR,在正常情况下,AR应该是和ISR一样的,但是当某个Follower副本落后太多或者某个Follower副本节点挂掉了,那么它会被移出ISR放入OSR中,kafka的选举也比较简单,就是把ISR中的第一个副本选举成新的Leader节点。比如现在AR=[1,2,3],1挂掉了,那么ISR=[2,3],这时会选举2为新的Leader。
「总监」:kafka中的 zookeeper 起到什么作用,可以不用zookeeper么?
「小扎」:zookeeper 是一个分布式的协调组件,早期版本的kafka用zk做meta信息存储,consumer的消费状态,group的管理以及offset的值。考虑到zk本身的一些因素以及整个架构较大概率存在单点问题,新版本中逐渐弱化了zookeeper的作用。新的consumer使用了kafka内部的group coordination协议,也减少了对zookeeper的依赖,但是broker依然依赖于ZK,zookeeper 在kafka中还用来选举controller 和 检测broker是否存活等等。
「总监」:kafka 为什么那么快?
「小扎」:顺序写:由于现代的操作系统提供了预读和写技术,磁盘的顺序写大多数情况下比随机写内存还要快。Zero-copy:零拷技术减少拷贝次数。Batching of Messages 批量量处理:合并小的请求,然后以流的方式进行交互,减少网络开销。
「总监」:消费者提交消费位移时提交的是当前消费到的最新消息的offset还是offset+1?
「小扎」:offset+1。
「总监」:你有什么要问我的吗?
「小扎」:请问这个职位主要的工作是什么?
「总监」:curd。
「小扎」:(lucky),好的,没其他什么要问的了。
「总监」:ok,那你在这稍微等一下。
「小扎」:好的,好的(接下来应该就是hr了)。
最终程序员小扎以50%的涨薪成功拿到了比特跳动公司的offer。
最后
不知不觉已经写了三期的程序员小扎面试记,虽说故事是瞎编的,但是内容是我自己想的,而且三篇面试记几乎不存在重复的知识点,创作不易,各位的点赞就是对作者最大的支持,也是作者最大的创作动力,我们下期见。
关注公众号【假装懂编程】,程序员小扎在这里等你。