引言
是的,诸位没有看错,这篇文章的要讲述的并不是我吊打面试官,而是一段我被面试官吊打的陈年往事,这段痛苦的记忆在我脑海中长久不衰,也是一个我内心曾多次不愿面对的事实,各位看官可以准备好一小把瓜子,听我将这则旧事缓缓道来~
写这篇文章的缘由是因为年前有不少小伙伴建议我写个
2022
年终总结,但我自己比较排斥写总结这类的,不过在脑海里回想近几年的技术生涯时,突然想起了这起有趣的事件,因而将其稍做分享,希望诸君认真读完本文之后,也能够给大家带来一些思考!
其实我与诸位一样,几年前的我排斥、甚至厌恶学习,毕竟知识从脑子过一遍后,一点也留不住的感觉我并不喜欢。但也是由于这次经历,才让我痛改前非,从一个不爱思考的“小码农”,变成了如今的“码农Plus
”,好戏开场!
因为近期工作较忙的原因,其实这篇文章,是从去年最后一天,也就是
2022
年12
月30
日开始,一直写到现在才完成的文章,标题也被我从原本的「追忆三年前」改成了「追忆四年前」,哈哈哈~
一、“被吊打事件”的前因后果
在正式谈及这次“被吊打事件”之前,首先来聊一聊此次事件的前因后果,事情的起因源自于我太过自信,刚毕业的那段时间,本故事的主人公,也就是我,经过一些特殊手段,成功入职了一家从事教育软件开发的小企业。当然,你要问我什么样的特殊手段能让刚毕业的我,无需面试就进入了一家软件企业,那就是大名鼎鼎的面试秘法——走后门。
因为我的一位亲戚,在这家企业担任级别不算低的“高管”,因此我靠常人不能及的手段成功入职,没错!俺是一个妥妥的关系户,也正式由于这个原因,所以入职后的工作任务并不算重,饭点前、下班前,冲在第一个的永远是我,毕竟实力摆在这,不嚣张点简直对不住我的身份,哈哈哈~
总之在入职第一份工作的时光中,我大致算整个研发部门中最轻松的那个,大致与沸点摸鱼区那群家伙的工作量类似[狗头],因为工作轻松,所以给了我不少
摸鱼学习时间,也正是通过这些时间,我在忙完工作之余的时间内,自身也额外学习了不少Java
技术。
当时我的技术大致是什么水平呢?这里我从招聘软件上将我当初的简历信息摘过来了,如下:
2019
年的时候,我当时的简历是这样的,但凡我当初听到过的主流技术,基本上都去做了相关学习,并且一半以上在开发中都用过,因为当时的研发模式属于低代码定制开发,因此核心平台的功能代码中,涉及到的新技术也蛮多,所以当初我有着迷之自信,自认为技术达到了 很牛逼 的程度。
正是由于当时这份简历,给予了自己莫大的自信,再加上成也萧何败也萧何,因为关系户的原因,我入职额外轻松,但也正因为是关系户,所以极大程度上限制了自己的成长空间,也就是抹不开面子去提涨薪,因此最终我做出了一个决定:“大丈夫生于天地之间,岂能郁郁久居人下”!
没错,当时的我毅然决然的“提桶跑路”了!提出辞职之后,在诸多的劝阻中,头也不回的卷铺盖走人,没有别的原因,完全归咎于个人对自己技术的自信!当时跑回了老家玩了一段时间后,想着男子汉大丈夫,是时候该有一番作为了!接着我去到了距离老家最近的省会城市,从此踏上了额外自信的面试之旅(上份工作不在老家的省份,这也是离职原因之一,玩心重,朋友都不在身边~)
从这段回忆中,大家应该能够感受出我当时的心态,用一个词去形容特别恰当,也就是“年少轻狂”,沉浸在自己的认知中,换当时的心理,如果非要找一个字来形容的话,那就是“我技术很屌”!哈哈哈,现在想起来感觉有些许幼稚,但当初的我确实就是这个心态,因自认为的技术飞速提升,造就了当时内心十分膨胀的我。
二、“被吊打事件”的正戏开场
2019
年国庆后的某个下午,阳光明媚,抬头望去,天空万里无云,也正是在这个时间点上,一位身着白色T恤的帅小伙,正在卖力的蹬动双腿。当时流行骑共享单车,在我约好面试后,就按约定的时间骑车赶往面试现场,经过近半小时的不懈努力,我成功在约定时间前赶到了,首先接待我的是一位人事小姐姐,在走完面试前的一些流程后,随即就喊来了一个技术老哥。
负责第一轮面试我的老哥,在简单看完我的简历后表现的很有兴趣,大概同我聊了有四五十分钟的时间,当然,这并不是本次的主题内容,所以按下快进键:
- 先简单问了一些关于
Java
基础的内容,如面向对象、集合、多线程、特性.... - 接着问了最近做的两个项目,整体的业务内容、核心技术、个人负责的技术工作....
- 然后又问了一些
JVM
相关的知识,如内存区域、垃圾回收、类加载机制、即时编译..... - 接着又聊了一些常用开源框架,如
Spring
事务原理、MVC
工作流程、AOP
的应用场景... - 然后又谈了一些关于分布式系统的技术,如分布式系统的一些解决方案、常用的中间件技术....
- 最后探讨了一下
MySQL
的知识,也就是索引、事务、锁的原理,以及SQL
优化、性能优化....
第一轮技术面试中,在我的记忆中回答的还算不错,包括当时负责一面的技术老哥似乎也挺满意,在聊完技术过了一会儿之后,他就带着我的简历去了人事部,紧接着又是最初的那位人事小姐姐,出来领着我往一个房间走去......
由于当时房间内还在谈话,所以我在门口稍等了一小会儿,但没过多久,我就被喊了进去,进门后映入我眼帘的,是一位梳着成功人士发型、穿着黑衬衫、打着小领带、并且面容较为英俊的男人,从面相上看应该在27、28
岁左右,当时给我的第一印象并不是油腻,而是一位很注重形象、并且风采气度不凡的老哥,他!就是本文的主角人物!
后来我从人事经理口中得知,他就是这个分公司的技术总负责人,也可以被称之为
CTO
、技术总监啥的,当然,在这里我印象最深刻的并非是他的长相和气质,而是那张超大规模的真皮沙发!毕竟我进门听到的第一句,就是他朝我说:“别紧张,过来这里坐”!我当时坐在真皮沙发上的第一感觉就是:真软!
三、“被吊打事件”的来龙去脉
- 前奏:先是简单的交谈,经过一番寒暄之后,终于正戏上演了!
- 技术总监:看了一下你的简历还不错呀,跟我聊聊你最近做过的这个项目吧。
- 我:叭啦叭啦叭.....一顿介绍。
- 技术总监:说说你在这个项目中,主要负责哪块开发呢?
- 我:个人参与了该项目的不少核心功能开发,如整个平台的用户模块,管理、身份、权限等.....
- 技术总监:OK,那咱们聊一聊登录注册吧,这个是你负责的对嘛?
- 我:是的。
3.1、第一问:登录、注册的业务设计
技术总监:你先跟我说说,你们这个项目的注册、登录界面是怎么样的呢?
注册、登录界面都大同小异,与一些主流网站类似,注册界面会要求用户输入「手机号/邮箱、昵称、密码、二次确认密码、验证码」等基本信息,等这些基础信息填写完成后,用户就可以点击按钮注册账号了。而对于登录流程的设计,相较来说就更为简单,只需要用户输入「邮箱/昵称/手机号」中的任一信息,然后填写密码点击登录即可。
技术总监:那如果用户登录时,忘记了密码怎么办呢?
对于这点是无需担心的,因为在登录页面上,提供了找回密码的入口,前面注册时必须要填写邮箱或手机号的,用户可以通过「手机验证码或邮箱验证码」的方式找回密码。
技术总监:嗯呢,那用户登录一次之后,第二次登录时还需要重新输入密码吗?
这个要看具体情况来区分,因为在登录的时候,提供了一个「记住密码」的选项,如果用户登录时勾选了该选项,这时在浏览器发出的「登录请求」中,除开基本的用户登录信息外,还会额外传递一个标识。
在后端判断用户输入的「用户名、密码」正确后,如果请求中存在该标识,则会生成一个Cookie
信息,将「用户名、密码」保存在Cookie
中返回,客户端在收到该信息后,会自动把Cookie
存储在浏览器的本地缓存中,所以当用户第二次登录时,避免了再次重复输入「用户名、密码」的工作。
技术总监:那你认为这种方式存在什么问题么?
有两个隐患,一方面由于保存的「用户名、密码」存在浏览器的本地记录中,所以如果在本地找到了对应的Cookie
记录,用户密码是有被盗取的风险。同时,如果并非用户本人操作电脑时,其他人通过「开发者工具」把input
元素的类型从password
类型修改成text
这种,依旧有可能造成用户密码泄露。
技术总监:可以的,逻辑思维能力还不错。
哈哈哈,没有的,主要是对于这块业务比较熟悉,而且登录注册的业务不算太复杂(这个时候我还没有意识到问题的严重性)。
技术总监:再问问你登录的设计哈。
技术总监:用户注册时填好了一部分信息,但因为有事走开了,最后电脑没电关机,用户重启电脑后,再次打开注册界面,需要重填信息吗?
在我们当时的项目中,如果出现这种情况,由于电脑已经重启了,因此用户上次填写的信息会丢失,需要用户重新从头填写注册信息。
技术总监:嗯呢,那你有没有好的办法解决这个问题呢?
「埋头苦思:不对劲,这小子很不对劲,这种问题怎么也问?让我想想该怎么回答。」
在用户填写数据的时候,前端可以通过「光标移出事件」来获取用户当前填写的数据,接着将其保存在本地的Cookie
中,如果用户点击了「注册」按钮后,则主动去删除Cookie
中的信息,毕竟提交注册后这些保存的信息就失去了作用。
但如果用户写到一半,电脑突然没电关机了,重启后再次打开注册页面,那这里又可以在「页面加载事件」中,从本地Cookie
中将原本保存的数据读出来,然后赋值给对应的文本框即可,从而避免用户重复填写数据。
技术总监:很棒呀,这个想法很不错!
技术总监:那假设有两个用户在同时注册,并且输入的用户名相同,同时提交注册会出现什么情况呢?
首先这种情况出现的几率比中彩票都小,同时就算出现了也没关系,因为不同地段的网速肯定有差距,所以两个注册请求到达服务器的时机也不同,同时在设计数据库的用户表时,对用户名加了唯一索引,所以两个用户同时注册时,就算输入的用户名相同,也只会有一个注册成功,并不会出现用户名重复的情况。
技术总监:你们项目除开通过注册账号登录外,还有没有什么其他方式呢?
还有第三方联合登录的实现,主要就是QQ、微信这两种社交账号的联合登录,是通过腾讯本身提供的API
来实现的,如果用户选择这种第三方登录,会直接去调用腾讯的登录API
。用户扫码登录成功后,会触发我们平台登录成功的回调接口,为其自动在平台注册一个账号,最终实现第三方账号的联合登录。
技术总监:好的,那咱们再聊点其他的。
3.2、第二问:注册时的敏感词检测
技术总监:你在做注册业务的时候,有没有考虑过,如若用户填写的「昵称/用户名」涵盖敏感信息怎么办呢?比如填写的昵称存在传播色情、违反政策规定、存在侮辱性含义等情况。
「沉默下来思考了几十秒,内心OS
:WC
,我还真没想过这块问题」
对于这块问题,当时在开发时并未考虑完全,因为这个平台属于定制化开发的,所以用户注册量也不算太大,因此在设计时也没往这块多想。
技术总监:没关系,那假设现在我让你去解决这个问题,你会如何下手呢?
「当时的我,因为做的都是一些简单的CRUD
/增删改查项目,所以被问的时候,脑袋有些断线,心理想的是:明明我都说没做过了,你偏偏还得往这块问,这纯属是在存心刁难我胖虎啊!」
但没办法,毕竟人家都问了,所以当时硬着头皮随便扯,当时的回答大致是这样的:首先我会在数据库里设计一张表,或者在后端里面创建一个Map、Set
这类的容器,专门用来存储「违规敏感词」,当用户注册时,在填写好「昵称」后,前端采用Ajax
异步请求的方式,将用户输入的「昵称」发给后端进行敏感词检测。
后端收到前端发送的Ajax
请求后,拿着用户的昵称去和「违规敏感词」进行匹配,如果用户输入的昵称中包含敏感词,那就让前端显示一下「昵称违规,请重新输入」,反之则通过验证,允许用户正常注册。
技术总监:思路不错嘛,那如果用户的昵称有七个字,但其中有两个字组成的词语属于敏感词,请问如何检测出来呢?
首先肯定需要先把用户输入的昵称分开,然后再进行敏感词检测,但由于个人未处理过该问题,所以目前不清楚具体的做法(其实具体方案是可以借助ElasticSearch
对用户输入的昵称做分词处理,然后再对分词后的结果进行敏感词检测,或者可以通过DFA
算法的方式进行敏感词检测)。
技术总监:没关系,既然你没具体做过,那咱们先跳过这个话题。
「我心中长呼一口气,终于跳过这个该死的问题了,再问下去都遭不住了!但没想到,我以为的结束却仅仅只是开始!」
技术总监:如果有人通过机器手段,如爬虫技术对平台进行账号的批量注册怎么办?
这点不必担心啊,因为前面说过的,在注册时用户必须要填写「手机号、或邮箱地址」,然后后端会先向对应的手机号或邮箱发送「验证码」,用户必须要输入正确的「验证码」之后,才能继续注册的,而手机号也好、邮箱也罢,基本上同一个人不会有太多个,所以通过「验证码」的方式,能够有效阻止机器批量注册。
也包括这个平台其实还支持第三方账号注册,也就是通过QQ
、微信的方式快捷注册,这种账号和「手机号、邮箱」类似,都具备一定的稀缺性,同一个人不会有太多的账号,所以基于这类稀缺性账号实现注册功能,都能有效的避免机器批量注册。
技术总监:好的,那一个手机号或者邮箱允许注册多个账号么?
这个是不行的,因为在后端有做唯一性判断,一个「手机号、邮箱」注册一次之后,就无法再利用它进行二次注册了。
技术总监:嗯呢,好的。
3.3、第三问:爬虫恶意调用短信接口做轰炸
技术总监:你有接触过、或者听说过短信轰炸嘛?
这个之前接触过,比如当你在网上和一个人起了争执,并且对方通过一些手段得到了你的手机号,他就可以拿着你的手机号,放到一些轰炸平台上去,然后这个平台就会频繁的给你发送一些垃圾短信,以此来实现轰炸、骚扰的效果。
技术总监:你说的很对,所谓的短信轰炸就是这么回事,但我想问你个事啊。
技术总监:你前面说过:用户在注册时不是可以选择手机号注册么?
技术总监:假设有人通过逆向分析,调试出了你们「发送短信验证码」的接口,接着用爬虫技术批量调用该接口轰炸别人怎么办?
「内心OS
:他*的,早知道说没听过短信轰炸了,我是真嘴欠啊,但后面仔细一想,好像不用担心这个问题!」
咳咳,对于这个问题嘛,其实也不必担心,因为当用户点击了「发送验证码」的按钮之后,首先会弹出来一个「滑块验证码」,只有当用户通过「滑块验证」之后,才会颁发一个调用接口的「数字签名」,如果不具备这个签名,直接调用「发短信验证码」的接口时就会返回「权限不足」的提示。
而作为一个正常人,通过「滑块验证」自然不成问题,所以当一个“人类用户”注册时,肯定是先拿到签名再调用「发短信」接口,如果出现未携带「数字签名」的请求,自然无法通过调用前的校验,因此通过这种滑块验证码的方式,就能有效防止爬虫的暴力调用问题。
其实当初身为一个
CRUD
仔的我,在被问到这个问题之前,一直并不理解为什么要在发送短信之前,增加「滑块验证码」这步反人类操作,毕竟一个简单的滑块,就连三岁小孩都能通过,因此当初在开发程序时,思来想去都不能理解这步操作!
技术总监:嗯呢,那如果对方通过Selenium
这种自动化技术,通过了你们平台的「滑块验证」,又或者说对方又调试出了「数字签名」的生成接口,从而得到了签名,依旧可以正常调用「短信」接口怎么办?
当我听到这个提问的时候,我很想回答一句:我!不!知!道!我只是一个天天摸鱼的螺丝仔,这不是纯属刁难人么!但不懂两个字决不能从我口中说出,因此当时随意间就扯了起来!
这个当时没有考虑到,毕竟前面跟您说过的,这个平台属于定制化程序,上线后面对的用户量并不算大,因此也没有考虑设计反爬虫机制,但您所说的这个问题也很好解决,对于一些较为“珍贵”的接口资源,比如目前所说到的短信接口,因为每条发出的短信都需要付费,所以通常情况下都会做调用限制,比如限制十分钟内只允许调用三次这类的。
技术总监:拿你所说的十分钟调用三次为例,如果一个人在第九分钟调用了三次,接着又在第十一分钟调用了三次,这样做是不是打破了调用限制呢?你认为是否有更好的方案代替呢?
当时回答的是:听您这么说,的确是存在一定的漏洞,从而让调用限制被打破,但这块没有去详细了解和接触过,所以并不清楚是否有更好的方案解决此问题。
对于这个问题,当时的确没有接触过,现在想来,他想听到的答案应该是“高并发情况下的限流方案”,而我回答的限流算法,属于最基本的计数器限流方案,除此之外还有时间窗口限流、令牌桶限流、漏桶限流这三种方案,下面对这几种常见的限流方案展开聊聊。
同时,对于「发短信」这类“珍贵性”接口,也应该做好接口的安全性设计,比如做好接口的防篡改、防重放,以及通过数字签名实现接口调用的高鉴权等措施。
3.3.1、计数器限流方案
计数器方案属于限流算法中最简单、并且实现难度最低的算法,比如以前面的案例来说,规定了「短信」接口的调用频率,不允许在十分钟内超出三次。
这时实现起来就很简单,在「短信」接口的类中,创建一个Map<String,AtomicInteger>
类型的容器即可,其中Key
存储用户ID
,而Value
则存储一个原子计数器,每当一个用户调用一次短信接口后,就将容器中对应的计数器加一,同时开启一个定时任务,每十分钟对计数器做归零重置。
当然,上述这种做法在用户量较大的情况下,显然会对程序造成较大的性能损耗,假设有
100W
用户,那就需要维护100W
个计数器,这会使得内存占用率直线飙升,同时还需要创建100W
个定时器,来分别维护每个用户的调用计数器。
更好一些的做法是借助中间件实现,比如基于Redis
缓存中间件来完成,将用户ID
设计成Key
,而Value
则是计数器,并且创建每个Key
时将过期时间指定为10s
,这样就能充分利用资源,不会造成太大的资源与性能开销,伪逻辑如下:
@Autowired
private StringRedisTemplate redis;
@RequestMapping("/sendSmsVerification")
public ResultVO sendSmsVerification(String sign, String userId){
// 用 SMS_ 拼接用户ID作为Key
String userIdSMS = "SMS_" + userId;
// 先通过前面生成的Key去Redis中进行查询
String value = redis.opsForValue().get(userIdSMS);
// 如果目前已经达到了调用次数限制
if ("3".equals(value)) {
return new ResultVO(200, "短信调用次数已达上限,请在十分钟后重试...");
}
// 如果该用户的Key在Redis中不存在,说明是第一次调用短信接口
if ("".equals(value)) {
// 首次调用短信接口时,则在Redis中创建一个计数器
redis.opsForValue().set(lockKey, 1, 10, TimeUnit.SECONDS);
}
// 如果该用户的Key在Redis中存在,说明并非第一次调用短信接口
else {
// 此时则通过Redis的incr命令,把对应的计数器加一
redis.opsForValue().increment(key);
}
// 省略其他业务代码......
}
这段限流代码并不算特别复杂,整体下来无非还是前面说的那几步:
- ①先通过用户
ID
拼接得到Key
,然后去Redis
中进行查询。 - ②如果查询出的结果为
3
,说明目前已达到了调用限制,则直接返回调用已达上限。 - ③如果查询出的结果为空,则说明用户是第一次调用短信接口,此时则在
Redis
中创建计数器。 - ④如果查询出的
value
和上面两条都不匹配,则对Redis
中的计数器加一。
这种计数器限流算法实现起来尤为简单,但前面也聊过它所存在的问题:临界问题,如果在两个时间单位的临界处调用,比如在第9:59
秒调用了三次,接着又在第10:01
秒调用了三次,那依旧会发生“超出调用上限”的情况,毕竟以十分钟作为单位,第9、10
分钟属于一个时间单位内,这时就超出了调用上限,调用次数达到6
次。
3.3.2、时间窗口限流方案
时间窗口限流方案被提出的主要目的,就是为了解决传统的计数器方案存在的临界问题,它的演变前身为TCP
协议的滑动窗口,如果对于TCP
协议较为熟悉的小伙伴,听到这个词汇相信一定不陌生,如若对这块内容并不熟悉的小伙伴也没关系,可参考之前文章中聊过的《TCP粘包、半包问题-滑动窗口》。
限流方案中的时间窗算法,主要可被分为固定窗口限流、滑动窗口限流两种方案,而前面聊到的计数器方案,实际上就是一种特殊的固定窗口限流方案,在前面的例子中,时间窗口大小为
10min
,速率限制为3
次,这种方案存在明显的临界限制问题。
下面重点聊一聊滑动时间窗口,这种方案是解决临界问题而被提出的,但对于滑动窗口的概念有些不好理解,所以先上一副逻辑图,如下:
在上图中,整个用虚红线圈出来的代表一个时间窗口,以上述例子来说,一个窗口的大小为600s/10min
,并且每个窗口被分为了三个单位,每个单位大小是200s
,这也就意味着每过200s
,窗口会向后滑动一个单位,这个动作也可以被称之为向后滑动一格,目前的窗口分布如下:
- 第一格:
0~200s
- 第二格:
201~400s
- 第三格:
401~600s
划分出来的每个格子,都具备各自独立的计数器,比如在第138s
时发生了一次接口调用,此时第一格的计数器就会+1
,还是以之前的例子来说:
第
9:59
秒调用了三次,接着又在第10:01
秒调用了三次。
将这里的分钟转换为具体秒数,也就是在第599s
调用了三次,第601s
调用了三次,此时来看,每当时间过去200s
,窗口就会向后滑动一格,这也就意味着整个窗口会变成图中的下面的样子,此时的窗口分布为:
- 第一格:
201~400s
- 第二格:
401~600s
- 第三格:
601~800s
当第599s
调用了三次「短信」接口后,第二格的计数器会累加到3
,此时再当第601s
尝试调用「短信」接口时,就会检测出已达到调用上限,此时就会拒绝用户的调用,以此来解决传统计数器方案的临界问题。
Why?Why?Why?
有些小伙伴可能到这里就有些晕了,第601s
是如何检测出调用超额的呐?因为目前的时间窗口范围是201~800s
,而将整个时间窗口内的计数器求和,就会得到调用总次数为3
,因而成功检测出了第601s
的调用上限。
当出现调用达到上限时,必须随着时间推移、窗口不断向后滑动,这样整个窗口的计数器总和才会下降,因此用户才能继续调用,通过这种方式就能控制一个时间段的绝对限流。
但滑动窗口限流方案就不存在临界问题吗?答案是No
,依旧存在,Why
?来看下图:
看上图中给出的案例,因为目前的时间窗口大小是600s
,而199s~203s
显然处于同一个时间窗口范围内,但随着窗口向后滑动,这里依旧会出现临界问题,也就是在一个窗口范围内,同样会出现打破调用次数上限的情况,那这种情况下又该如何解决呢?其实答案很简单,把一个窗口的格子单位调小即可。
比如直接将每一格的单位大小从
200s
调整为1s
,此时每过一秒钟,窗口就会向后滑动一格,等到100s
秒过后,窗口会向后滑动100
格,此时窗口的区间范围是101~700s
,这就将199~203s
这个范围包含了进去,因此上述情况自然就不会出现!
经过上述分析由此可以得出一条准则:当滑动窗口的格子划分的单位越小,整个窗口中的格子数量会越多,滑动窗口的向后移动就越平滑,限流的统计就会越精确。
3.3.3、令牌桶限流方案
前面简单聊完了时间窗口限流方案后,接着再来聊一聊大名鼎鼎的令牌桶限流方案,令牌桶算法是一种类似于“池化”思想的产物,算法的大体过程如下:
- ①初始化令牌桶并设置最大令牌数,当桶内的令牌达到阈值时,新添加的令牌会被拒绝或丢弃。
- ②根据限流大小,启动一条线程,并按照一定速率向令牌桶中不断添加新的令牌。
- ③任何处于「限流范围」内的请求,都需要先获取到一个可用令牌,然后才会被处理。
- ④当一个请求获取到可用令牌后,才会真正执行业务逻辑,执行完成后会将此令牌从桶内移除。
- ⑤令牌桶除开有最大令牌数外,也会有最小令牌数,当桶内令牌数小于最小阈值时,处理完请求并不会移除令牌,而是会将令牌还给令牌桶。
对于令牌桶限流算法,理解起来并没有前面的滑动时间窗口复杂,但唯一要注意的是:当桶内的令牌被一个请求获取后,此时并不会立马从桶内移除,该令牌会依旧停留在桶内,只不过该令牌的状态会从可用状态变为不可用状态,也就是其他请求无法再获取该令牌,真正移除令牌的工作,会在业务逻辑执行完成之后才触发。
3.3.4、漏桶限流方案
漏桶限流和令牌桶限流都属于桶类型的算法,但漏桶算法更类似于MQ
消息队列,其算法的执行示意图如下:
想要理解漏桶算法,咱们先来看看日常生活中的漏斗,比如现在我要用漏斗来给摩托车加油:
倒油时,我们可以用瓶子,也可以用桶子,也可以用加油枪.....,这也就意味着:漏斗上方的进油速率并不固定,但不管上方的进油速率如何,下方的漏斗出口,其速率确实固定的,无论上方进油多快,都不能影响下方的出油速率。
理解了日常生活中的漏斗后,接着再来看看前面的漏桶限流算法,请求会从漏桶上方进入,而服务端则只会按照固定速率去处理请求。此时思考一个问题:当请求进入的速率大于请求处理的速率,会发生什么情况呢?
此时依旧回到用漏斗给摩托车加油的例子中,如果漏斗上方的倒油速度比较快,而由于漏斗的结构原因,下方的出口跟不上进油速度,此时漏斗中的油量会直线上升,直到超出漏斗的最大容量时,再进入漏斗的汽油会溢出。
而限流中的漏桶算法同样如此,请求进入的速率大于请求处理的速率时,多出来的请求会被放入桶中等待,当桶内阻塞等待的请求超过最大限制后,后续进入的请求会被丢弃或拒绝。
从上述的讲解中,诸位应该能够明显感受到漏桶算法的特点,即:宽进严出,该算法中不会限制请求进入的速率,但会限制请求处理的速率,一些对稳定性要求较高的系统,就可以采用该算法对系统进行限流。当然,如果熟悉MQ
的小伙伴也能感受出:漏桶算法和MQ
的削峰填谷有着异曲同工之妙,当系统峰值流量较高时,会将请求写入到MQ
中,然后再由具体的业务服务,按照固定的速率拉取MQ
中的消息进行处理。
3.3.5、高并发限流算法小结
在前面共计提到了计数器、滑动窗口、令牌桶、漏桶这四种常规的限流方案,但要记住:并不存在一种适用于任何场景的限流算法,根据业务的需求不同,系统的关注面不同,应当采用不同的限流方案,没有所谓的最好!最后简单说一些成熟的限流实现:
Guava
中的RateLimiter
工具类:基于令牌桶实现的限流组件,并且对其进行了预热拓展。Sentinel
中的匀速排队限流策略:基于漏桶思想的限流策略,内部采用队列进行实现。Nginx
的limit_req_zone
限流模块:基于漏桶思想的限流模块,实现网关层的限流控制。........
3.4、第四问:API接口的幂等性问题
技术总监:接下来我们再聊聊其他方面的可以吧?
技术总监:以目前的技术来说,任何用户在使用网络时,难免会存在延迟是不是?
对的,这点我深有体会,尤其是在过年回老家的时候,由于山区的网络覆盖并不全面,所以在访问一个网站时,加载的速度会特别的慢。
技术总监:嗯呢,既然你也说了这个问题,那我再问你一个问题。
技术总监:如果一个用户在注册时,网络比较卡顿,所以提交注册后迟迟没有反应,因此他又连续点击了多次「注册」按钮,此时会发生什么情况呢?
「我沉思片刻回答道」:如果没有做任何限制,理论上会向服务端发出多次请求,如果数据库的表结构设计不合理,那么还会出现同一用户的注册信息,在用户表中被插入多次。
技术总监:说的不错,那请问你们当时是怎么处理呢?
我们当时处理方案比较简单,首先在前端做了一定限制,也就是当用户首次点击了「注册」按钮后,「注册」按钮就会变成灰色,也就是用户再次点击时,并不会再次发送Post
请求向后端提交表单数据。
技术总监:那如果用户看点击注册按钮后迟迟没反应,按F5
刷新或浏览器的后退键,接着再次点了「注册」按钮怎么办?
「心里一颤,没想过啊!硬着头皮解释道」:对于此问题,我在做登录注册时并未考虑周全,未对这个问题进行思考。
但其实现在想来,解决的思想也比较简单,除开在原本将按钮变灰的基础上,再加上一个「重定向页面」即可,比如信息提交后就跳转下述这个界面:
这样做的好处在于:重定向操作发生后,当用户再次刷新网页,或者通过浏览器的回退键,回到原本的界面时,之前表单中填写的信息并不会保存。这样做的好处在于:用户想要再次点击注册按钮,就只能再次重新输入信息。
在用户网络比较卡顿的情况下,做了上述设计后,就只会出现两种情况:
- ①用户上次点击「注册」按钮提交的
Post
请求发送失败,服务端并未处理上次的注册请求。 - ②用户上次点击「注册」按钮提交的
Post
请求发送成功,在用户再次填写信息的过程中,服务端将上次的注册请求成功处理,用户再次提交注册时,系统会直接提示去通过手机号登录。
总之加入了这个「重定向页面」后,都能保障在短时间内,用户无法再次重复提交参数相同的注册请求。
技术总监:那如果有人通过PostMan
之类的工具,模拟注册参数多次调用注册接口呢?
这个实际上也不需要担心的,因为在数据库的表设计中,我们对「邮箱/昵称/手机号」这些特殊字段也加了唯一索引,就算特殊情况下造成重复请求出现,由于表结构中有唯一性字段,所以对于相同注册参数的请求,在用户表中依旧只会成功插入一条数据。
技术总监:这种方案也可以,但你还有没有什么其他更好的方案呢?
当时项目是这么做的,所以并未再去对其他方案进行研究。
技术总监:没事,你等面试结束之后可以再研究一下。
3.4.1、接口幂等性设计的最佳实现
虽然当时并未回答出更好的方案,但后续自己也去了解过「接口幂等性与防重设计」,这里做简单总结。
产生幂等问题的根本原因
总的来说,在软件系统中出现幂等问题的原因无非四个:
- ①用户重复提交:一般是指用户填写好表单信息后,由于响应较慢,从而多次点击提交按钮。
- ②非法调用:指第三方通过逆向手段调试到了接口地址,然后通过爬虫或接口工具多次调用。
- ③失败重试:指分布式项目中,被调用方出现超时或异常时,触发了调用方的重试补偿机制。
- ④重复消息:通常指引入
MQ
的项目,对于同一个消息,生产者多次发送,或消费者重复消费。
会出现幂等问题的操作
作为开发者的我们都知道,任何一个软件,不管业务多么复杂,其背后的本质依旧是增删改查,对于删、查操作而言,天然具备幂等性,因此需要考虑幂等性设计的就只有增、改这两种,Why
?
因为查询、删除操作,就算出现多次也并不影响整体数据的一致性,比如查询“张三”的年龄,同一时间内无论查多少次,得到的结果都是相同的。而删操作同样如此,如删除姓名为“张三”的用户数据,就算同一时间内出现了十个这样的请求,最终结果都是“张三”这条数据不见了。
多个层面解决幂等问题的方案
- 前端:
- ①按钮变灰/或变为
Load
状态:防止用户点击多次按钮,造成多个重复请求出现。 - ②重定向页面:防止用户通过刷新/回退的方式,造成多个重复请求出现。
- ①按钮变灰/或变为
- 后端:
- ①唯一
Key
方案:先根据业务参数,从中选出或计算出一个全局唯一Key
:- 唯一
Key
的计算方案:- 选用请求参数中的某个特殊值,如手机号、订单号...作为
Key
。 - 通过
Hash
函数来对所有参数进行哈希计算,得到一个Key
。 - 非注册的场景,可以使用当前用户
ID
+目标方法名作为Key
。 - .....(这里只要能得到一个与业务相关的唯一
Key
即可)。
- 选用请求参数中的某个特殊值,如手机号、订单号...作为
- 得到唯一
Key
之后,通过set nx px
命令向Redis
插入数据:- 成功:代表前面没有重复的请求,当前请求可以执行。
- 失败:代表前面有相同请求已经插入过了,当前请求需要被丢弃。
- 唯一
- ②防重表方案:使用业务的唯一
ID
,如订单号作为唯一索引,操作之前先插入防重表。 - ③状态机方案:在表上多加一个状态字段,对于
update
操作加上状态判断,如订单表:- 将「待付款」改为「待发货」:
update ...,status = 2 where status = 1;
- 这样就算出现多个修改请求,因为第一个请求改成功后,状态变为
2
,其他请求都会失败。
- 将「待付款」改为「待发货」:
- ④
Token
方案:内容较多,后面聊。
- ①唯一
- 数据库:
- 乐观锁方案:额外设计一个
version
版本字段,但这种方案只适用于update
操作。 - 唯一索引:对于数据的关键字段加上唯一索引,如手机号,避免重复数据多次插入。
- 乐观锁方案:额外设计一个
上面根据不同的层面,给出了多种幂等问题的解决方案,但有些方案只适用于特殊的场景,如状态机、乐观锁、防重表等方案,如果要设计一套解决幂等问题的通用方案,选择如下:
- 甲、前端重定向页面防重 + 后端唯一
Key
去重 + 数据库唯一索引兜底。 - 乙、前端按钮变灰防重 + 后端
Token
去重 + 数据库唯一索引兜底。
通过上述这两套组合方案,任选其一都能够打造出一套解决幂等问题的通用策略,但其中唯一没展开讲解的则是Token
方案,这种方式到底是如何实现的呢?下面展开聊一聊,示意图如下:
- ①当用户进入一个表单时,前端通过
Ajax
异步调用后端提供的Token
获取接口。 - ②后端生成一个全局唯一性的
Token
放入Redis
中,可以是UUID、SnowflakeID....
。 - ③后端将生成的
Token
返回给前端,前端先将其保存在一个变量或Cookie
中。 - ④用户填写好表单数据后,在
Post
请求的头部携带Token
值,接着与表单数据一起发给后端。 - ⑤后端先获取头部的
Token
值,并尝试去Redis
中删除该Token
,即del [token_value]
。 - ⑥后端根据删除命令的执行结果,进行下一步判断:
- 如果成功删除:表示目前请求是第一次调用接口,允许执行具体的业务逻辑。
- 如果删除失败:表示该
Token
之前已经删过了,当前请求属于重复请求,应当被丢弃。
上述即是前面所说的Token
方案,整个过程会出现两个请求,第一个请求是异步获取Token
,第二个请求则是具体的业务请求,最后会基于业务请求上携带的Token
值,以此作为重复请求的判断条件,从而避免同时处理多个重复的请求。
3.5、第五问:用户账号的合并问题
技术总监:你之前说过,你们项目注册时,可以选用「邮箱/手机号/第三方账号」进行登录是吧?
对的,用户可以通过这三种方式来注册并登录平台。
技术总监:那一个用户通过手机号注册后,能否绑定第三方账号呢?
这个是支持的,在用户的个人中心里,用户可以选择绑定第三方账号,绑定第三方账号后,后续用户也可以直接通过第三方账号登录。
技术总监:那假设用户先通过微信进行第三方登录,按你们平台的规则,会自动为其注册一个账号。
技术总监:接着该用户又用手机号注册了,此时同一个人在你们平台,是不是有了两个账号?
是的,通过微信登录时,如果之前这个微信没有绑定过平台账号,会为其自动创建一个账号。用户通过手机号进行注册,同样又会生成一个账号。
技术总监:嗯呢,那我想问一下,如果这个用户有一次通过手机号登录,接着想要绑定那个微信,这样可以吗?
我听到这个问题,第一反应是想回答:“可以”!
但转念一想发现了端倪,如果能绑定同一个微信,岂不是一个微信对应两个平台账号了?假设该用户下次选择通过微信扫码登录,扫码成功之后,到底要登入哪个账号呢?
「我理清思路回答道」:这是不可以的,因为这样绑定之后,一个微信号会与两个平台账号产生映射关系,下次用户选择用该微信号登录时,就会出现问题,无法确定要登入哪个账号。
技术总监:既然你能想明白这个问题,那我想问问你有没有什么好的解决方案呢?
「我听到这个问题后,陷入了沉默.....」
3.5.1、站在现在的角度再次看待此问题
其实这个问题本身并不是技术问题,而是一个业务问题,所以想要解决此问题,就无法完全依靠程序自己完成,此时必须介入人工进行处理,而这个问题在如今的各大平台都有解决方案,大体归为下述五类:
- ①选择第三方登录时,需要用户通过手机号先创建一个平台账号。
- ②合并多账号的权利交给用户自己。
- ③当用户尝试绑定一个「已绑微信」时,提示用户找管理员申诉。
- ④允许同一个第三方账号对应多个平台账号,扫码登录时,选择登录哪个账号的权利交给用户。
- ⑤用户想要绑定一个「已绑微信」时,提示用户先去解除该微信与其他账号的绑定关系。
第一种做法在各大银行的手机
APP
中比较常见,当你选择通过第三方账号登录手机银行时,如果是第一次登录,微信登录成功后会跳转注册界面,要求你先通过手机号创建一个账号,接着银行APP
会自动将当前「手机号、微信」产生绑定关系,后续可以两者中的任一方式登录。第二种做法我在简书见过,当多个账号之间存在冲突时,将合并账号的权利交给用户自己,当用户选择保留某个账号时,其他账号都会被销毁,包括其他账号在平台上的所有数据也会彻底丢失。
第三种做法我在一些小的自建站见过,其实这是触发了平台的「未知操作」的补偿机制,由于用户在尝试绑定一个「已绑微信」,这种操作在程序后台无法识别,所以直接给出统一的提示,即:“请联系管理员进行申诉”,申诉后会由平台管理员,介入修改后台数据库进行处理。
第四种做法在游戏的用户管理中比较常见,以广为人知的「王者荣耀」举例说明,在登录界面可以选择通过微信登录游戏,而微信登录成功之后,会出现下述这个界面:
在这类游戏中,玩家可以自行选择分区,同一个微信账号支持在多个分区创建账号,这也就意味着一个第三方账号,可以与多个平台账号存在关联关系,当用户下次通过该微信账号登录时,用户可以自行选择具体的分区(具体要登录的平台账号)。
第五种做法属于最常见的做法,明确规则一个第三方账号,只能与一个平台账号存在绑定关系,当一个账号尝试绑定第三方账号时,如果检测到对应的第三方账号存在其他的绑定关系,就直接提示用户:“该第三方账号已被其他账号绑定,请手动解除绑定后重试”!
3.6、第六问:登录的夺命五连问
技术总监:用户登录成功之后,第二天再次打开网站需要重新登录吗?
如果用户登录成功之后,第二天再次打开网站无需再次登录,但「免登录」存在时效限制,一般情况下为7
天,也就是距离用户上次登录的时间超出七天后,用户再次访问网站就需要再次登录。
实现的大体原理:通过
JWT
实现,用户登录成功之后,后端往Redis
中存储一个时效七天的refresh Token(Key=userID,value=refreshToken)
,接着会向前端颁发一个时效较短的access Token
,前端会将其存储浏览器本地,在后续每次客户端访问当前网站时,都会携带这个access Token
完成鉴权。颁发给前端的
access Token
时效为何比refresh Token
要短呢?
有些业务对权限比较敏感,为了Token
避免被盗用,access Token
自然是有效期越短越安全。时效较短的
access Token
过期了怎么办?
当一个客户端携带过期的access Token
来请求时,服务端可以通过该Token
解析出时间戳和用户信息,效验时间戳没有问题后,接着通过用户信息中的userID
去查Redis
,如果能够查询到对应的refresh Token
,此时就可以重新签发一个access Token
返回给前端,前端将之前的Token
替换成新的后,再次请求服务端资源。
这个过程会不断循环,周而复始之,直至服务端Redis
中的refresh Token
过期为止(过期后需要用户重新登录)。
技术总监:用户登录成功之后,其他的子系统如何得知该用户登录了?
因为不同的子系统都有权限控制,一个用户在主站登录成功之后,服务端会向客户端颁发Token
,客户端可以通过该Token
在主站域名下“活跃”,但当客户端尝试访问其他不同域名的子系统时,由于浏览器的本地数据(缓存、Cookies
等)是按域名区分存储的,所以访问其他子系统时并不会携带前面主站颁发的Token
,最终客户端的访问会遭到拒绝。
现如今业务线愈加复杂,因此都会引入分布式概念拆分出不同的子系统,并且不同的业务子系统会采用不同的域名部署,所以想要保证「用户一次登录,全线都能访问」的功能,就需要实现单点登录功能。在我们项目中,当时通过OAuth2.0
整合JWT
实现了SSO
认证服务,从而最终实现了单点登录的功能。
简单概述
OAuth2.0 + JWT + SSO
实现单点登录的原理,如下图:前提:
①当用户在访问任意子系统没有携带Token(Ticket)
时,都会被重定向到独立部署的SSO
认证中心。
②如果对应的用户在SSO
服务中找不到登录凭证,最终会跳转登录页面,要求用户进行登录操作。
一次完整的单点登录过程:
- ①用户未携带
Ticket
访问A
系统的某个页面,被重定向到SSO
服务。 - ②用户未携带登录凭证访问
SSO
认证中心,被重定向到登录页面。 - ③用户完成登录操作,在
SSO
域的Cookie
中植入各种凭证,并再携带Code
重定向到A
系统的回调接口。 - ④用户携带
Code
访问A
系统,A
向SSO
请求验证Code
,有效则为A
域颁发Ticket
,并重定向到原网页。 - ⑤用户携带
Ticket
访问A
系统的原网页,A
向SSO
请求校验Ticket
,有效则执行具体的业务逻辑。 - ⑥用户访问
B
系统的某个页面(此时无法携带A
域的Ticket
),被重定向到SSO
服务。 - ⑦用户携带
SSO-Cookie
访问SSO
,该用户的登录凭证校验成功,携带Code
重定向到B
系统的回调。 - ⑧用户携带
Code
访问B
系统,B
向SSO
请求验证Code
,有效则为B
域颁发Ticket
,并重定向到原网页。 - ⑨用户携带
Ticket
访问B
系统的原网页,B
向SSO
请求校验Ticket
,有效则执行具体的业务逻辑。
为什么可以通过
Code
换Ticket
呢?利用OAuth2.0
的四种授权方式之一:授权码来实现。
为什么要用Code
换Ticket
呢?Code
是一次性的,降低Ticket
被盗用的风险。
技术总监:用户复制一个登录后才能访问的链接,然后粘贴到另一个页面上会怎样?
这要分情况,如果用户复制链接之后,粘贴在同一个浏览器的其他页面,此时该用户是可以正常访问的。但如果用户复制链接粘贴到其他浏览器上,在其他浏览器未登录过的情况下,本次访问都会遭到拒绝。
这是因为后端都对用户做了权限控制,如果未登录账号的客户端,在我们平台属于游客级别,而登录了账号的客户端,则属于正常用户的级别,不同的用户级别对应不同角色,不同角色则又对应不同权限,以此来实现权限的精准控制。
这里背后的实现原理就不过多啰嗦了,当时的项目是采用
Shrio
权限框架实现的,所有的权限、角色、用户的映射关系,都存储在数据库的五张权限表之中(有兴趣的可以自行去了解)。
技术总监:用户点击登录之后把当前页面关了会发生什么?
「思索片刻后不自信道」:额....,应该会登录成功吧。
技术总监:确定会登录成功么?
「陷入沉默.....」(内心:我擦,这纯属刁难人啊,那个吃饱没事干的人,会点了登录就关网页!?!!)
站在现在的角度思考:
结论:是否会登录成功要分实际情况来决定,看用户关闭的是当前网页,还是当前的浏览器。
用户关闭了当前网页,结果是会登录成功。用户关闭了当前浏览器,结果是不一定登录成功。原理分析:
关闭当前网页:因为用户点击登录按钮之后,登录(账号、密码)的请求已经发往了服务器,所以服务端处理完登录请求后,最终会返回一个Token
或登录凭证,此时由于浏览器进程还在,这也就意味着浏览器自带的网络进程并未消失,所以登录效验成功之后的操作,如在Cookie
中植入Token
、各类凭证等操作依旧能正常完成,所以理论上会登录成功。
关闭当前浏览器:这种情况下,用户点击登录按钮后,依旧会向服务器发出登录请求,但由于浏览器已经被关闭了,所以相应的网络进程也会消失,最终就会出现一种特殊现象:「当服务端处理完登录请求后,向客户端返回响应结果时,由于客户端的网络进程已经销毁,所以浏览器无法接收响应结果,也就自然无法在Cookie
中植入各种登录凭证,最终结果就不一定登录成功」。疑惑解答:
为什么关闭浏览器之后无法接收服务端的响应结果?
因为HTTP/HTTPS
协议的底层是TCP
协议,TCP
是一种双向通信的网络协议,当通信的一端出现故障时,两端之间的网络数据就无法正常传输,期间TCP
的发送方会多次重新发送数据包,但由于接收端的网络进程已销毁,所以无法收到响应结果。
为什么关闭浏览器的结果是不一定登录成功?
因为存在不稳定因素,毕竟大多数进程在退出时,采取的措施都是优雅关闭,也就是会先处理完目前正在执行的任务后,才会真正将所有后台进程退出(也就是大家关闭一个程序之后,电脑管家都会提醒你XX
软件还有残留进程可清理的原因)。
如果关闭浏览器之后,网络进程没有立马销毁,在这期间可能会正常收到服务端的响应结果,最终就会登录成功。
但如果服务端的响应时间比较慢,或者用户安装了电脑管家之类的程序,在进程退出后也许会自动清理残留进程,这种情况下就会彻底销毁网络进程,此时结果就是登录失败。
技术总监:用户点击登录之后把网线拔了,你认为结果是怎么样的?
「当时的心情:.....................................」
「当时的内心:我去你大爷的,你*&#...~-/!,前面的点登录按钮后关页面就够离谱了,你现在又整一个拔网线...,你怎么不问我用户点击登录之后,地球就爆炸了会怎样呢???」
「我的回答」:不知道!(当时到这里心态都被问出一点问题了)
以现在的知识储备进行理性思考:
结论:具体要看用户拔网线的时机,结果依旧可能是登录成功或登录失败。
如果用户在响应结果回来之后拔了网线,结果是登录成功。但如若响应回来之前拔了网线,结果是失败。原理分析:
这个问题其实和上一个问题类似,但实际情况又存在很大差异,因为不管什么时候拔网线,本质上浏览器的网络进程都不会消失,问题在于网络传输链路出了问题。
对于接收到响应结果之后才拔网线的情况,理解起来也比较容易,毕竟响应结果都拿到了,剩下的工作自然也能进行,最终结果就必然是登录成功。但此时重点要说明的另一种情况,也就是:为什么在响应结果回来之前,拔掉网线的结果是登录失败?
想要明白这个问题,本质上与计算机网络的基础脱不了干系,众所周知的一点,现如今的互联网是由一个个局域网组成的(不了解的小伙伴回去看《计网基础:漫谈计算机网络》),由于IP
属于珍贵性资源,所以并不是每台网络设备都具备公网IP
,而恰恰远距离的网络通信需要公网IP
,此时又该怎么办呢?那也就是多台网络设备共享一个公网IP
,这些共享一个公网IP
的多台机器,会组成一个小的局域网(如果理解比较困难,可以这样理解:插同一个路由器网线、连同一个路由器WiFi
的设备,都可以看成是一个局域网内的设备)。
有了上述知识的简单储备后,接着再回到问题本身进行探讨,当用户的浏览器发出登录请求,并且服务端将用户的登录请求处理完成后,经一系列处理会产生一个数据报文,该报文的目标地址就是发出登录请求的那台机器(实际上是那台机器所在的局域网的公网IP
),接着响应报文会先来到机器所在的局域网,但此刻问题来了!
响应报文已经抵达了局域网,不过此刻用户的电脑网线被拔,也就是对应设备会退出这个局域网,那么局域网的路由器在“派送数据报文”时,就无法找到具体的派送目标,但此时用户电脑上的浏览器网络进程依旧存在,只是由于传输链路出现故障,所以无法接收到响应结果,最终导致登录失败。
这种情况就相当于买快递,原本你写的是收货地址A
,当快递送到A
小区的菜鸟驿站时,结果你搬家搬去了B
小区,这时A
小区的驿站派送员,就无法根据收货地址将快递送货上门。
当然,还有一种特殊情况,也就是用户把网线拔了之后,又立马插上去了,这时理论上还是会登录成功的,因为HTTP
底层的TCP
协议,是一种可靠性传输协议,在传输失败的情况下会有重发机制。
3.7、第七问:令人窒息的多IP并发操作
技术总监:一个账号在多台电脑上同时点击登录按钮,最后会出现什么情况呢?
「吸收前面的教训,听到这个问题的我,第一反应就是这里面绝对有诈!」
「经过一番思考后,回答道」:应该都会登录成功。
技术总监:哦?也就是你们的项目中,并未限制多IP
登录,或者做同端互斥对吗?
「我仔细想了想,好像确实没做,于是回答道」:在我们的项目中确实没做这些。
技术总监:那假设一个账号在两个IP
上登录了,同时修改昵称会发生什么变化?
有一个IP
上修改的昵称,会被另外一个IP
上的昵称替代掉。因为就算两个IP
同时修改、同时提交,最终到数据库执行update
语句时,都会被串行化,因为两个事务并发修改同一行数据时,需要先获取行锁资源,这也就意味着这两个修改操作最终都有前后之分,前一个IP
修改的昵称总会被后一个IP
修改的昵称覆盖掉。
技术总监:嗯呢,那在不限制多IP
登录的情况下,你有什么好的办法结果这个问题吗?
「仔细推导一番后,回答道」:可以加入一个中间状态,也就是在用户表中多设计一个状态字段,0
代表正常状态,1
代表审核状态,当用户的信息发生变化后,对应的用户记录都会被改成「审核中状态」,而执行语句时只允许修改正常状态的用户记录,伪SQL
如下:
-- 之前的SQL语句
update zz_user set user_name = "竹子爱熊猫", ... where user_id = 888;
-- 优化后的SQL语句
update
zz_user
set
user_name = "竹子爱熊猫", status = 1, ...
where
user_id = 888 and status = 0;
通过这样的手段,在第一个IP
修改成功之后,第二个IP
就无法满足SQL
语句的执行条件,最终就无法真正修改用户数据。
技术总监:很不错的思路,的确能够解决我所提出的问题。
技术总监:如果现在有一个签到领积分的功能,两个不同IP
的同一账号同时签到,会不会领到双倍积分?
如果没有做任何限制措施,这种情况下应该会领到双倍积分,但前提是两个IP
是以绝对手段进行同时操作的才行,也就是服务端中同一时间内,两条线程并行处理两个IP
的签到请求。
技术总监:嗯呢,那如果你项目中有订单功能,一个IP
删除订单,一个IP
结算订单,两个操作同一时刻内进行,结果是什么呢?
会出现问题,导致一个账号上的订单数据错乱。
技术总监:那你认为该怎么解决此问题呢?
当时的我听到这个问题,心里的第一想法:得加锁,但又转念一想,似乎发现了不对劲,因为加锁只能让并行操作串行化,但最终两个业务操作总会执行的,这里加锁之后只会出现两种情况:
①删除订单的请求先获取锁,先删掉了订单,结算订单的请求无法执行结算业务(因为订单都没了)。
②结算订单的请求先拿到锁,用户付钱结算了订单之后,删除订单的请求获取到了锁,然后把用户已经付钱的订单删了(这显然更不合理,用户估计能举起四十米的大刀...)。
「由于当时的我没做过并发处理,就只懂一些简单的多线程理论,于是又陷入了沉默.....」
站在如今的角度出发,再次看待此问题,解决方案为:状态机!啥意思呢?其实和之前「并发修改昵称」的方案差不多,单独的靠加锁无法解决此问题,问题并不在这上面,这同样是个业务逻辑的问题,应该在订单表上面也设计一个
status
状态字段。
订单表的状态字段,可选状态如下:
0
:待结算(待支付)。1
:待发货。2
:待收货。3
:已签收。.....
9
:已销毁。
有了上述这个状态机字段后,再回过头来看「删除订单、结算订单」这两个业务操作,本质上都是执行update
操作,删除是将状态改为9
,结算是将状态改为1
,所以SQL
语句只需要新增一个条件即可:
-- 删除订单(只允许删除待结算、已签收的订单)
update zz_order set status = 9,... where status = 0 or status = 3;
-- 结算订单(只允许结算待支付的订单)
update zz_order set status = 1,... where status = 0;
也就是直接通过状态字段限制其他并发操作,无论「删除订单、结算订单」谁先执行,另一个操作都无法继续执行。有人也许会疑惑:有了状态机之后,就不需要加锁了吗?
其实这种情况下,加不加锁就无所谓了,因为
MySQL-InnoDB
本身有行锁机制,多个事务并发修改同一条数据,都会被串行化执行,因此在后端加锁,只是将请求串行化的工作提前罢了,这反而会影响整体的性能。
「其实到这里还并未结束,后来这位面试官还与我聊了许多,但由于时间较为久远,就只能回忆起一些印象比较深刻的提问了~」
四、这段难忘经历给我带来的感悟
可能看到这里,大家很感兴趣的一点是:后来的我怎么样了?其实这次面试之后,当时的我不气馁是不可能的,甚至那时的我被打击的有些严重,自以为不可一世的我,结果死在了“最简单的登录注册”上....
结束了这次面试后,我并未再继续投递简历,但人总得吃饭是不?于是乎,我又使出了另一种赫赫有名的面试秘法 —— 朋友内推,在第二天以满意的薪酬,成功入职了另一家公司~
当然,其实后来这家外企的人事也在后面一周的周一,给我打来了入职邀约的电话,但由于我已经入职了朋友公司,所以用「临时有事,不方便过去入职」的理由拒绝了(我原本以为自己肯定凉了,毕竟三四天都没有给我通知,但后面转念一想,毕竟这是外企的分公司,可能是入职审批流程比较长)。
不过令人出乎意料的是:在当天的下午,该外企的人事总监又打来了一个电话邀请我入职,说他们技术总监比较看好我,感觉我很具备培养价值....,而且这回的入职邀约中,可能以为我上次拒绝是薪资不满意,还额外在我报价的基础上加了1.5K
的工资(这对当时的我来说,虽然不算特别多,但每个月多出1.5K
也是一笔不菲的收入),不过最终还是因为多方面的原因拒绝了,哈哈(其实早两天打给我说不定真的会过去~)。
虽然这次面试带给我的打击很大,但从中我的收获也不小,其实总的来说也算咎由自取,毕竟当时的我的确很骄傲,而这位
CTO
则用我当时认为“最简单的业务”,将我虐的体无完肤,从这段经历中我想明白了一个道理:谦虚戒骄才是真正的大佬应有的美德。
当然,说了这么多的过程,最后也来聊聊这段经历带给我的感悟,扪心自问,其实这位面试官也是人生中的一位“贵人”,从他身上我看到了很多之前并不知晓的道理。
4.1、千万不要抱怨自己只是个CRUD的螺丝仔
在现在的开发环境中,很多人都会抱怨工作:“天天都是负责业务的增删改查,这种日子什么时候是个头啊,不想一直再做CRUD
的螺丝仔了”!拥有这种心态的人不在少数,谁的心里都有个梦,起初的我也并不例外,「架构师、CTO
、技术总监、技术专家.....」,面对这一个个高大上的职位,曾经的我也憧憬过,时常幻想着什么时候我也能成为这样的人啊,这头衔说出去就倍有面子.....
但等到了这些职位的时候,你会发现每天的工作还是和业务打交道,泡泡茶畅谈未来技术?用技术在项目中指点江山?沉沦在技术中做架构选型?其实这些都不存在,每天其实依旧在围绕着业务兜兜转转,「高职技术人」和「普通开发者」之间,唯一区别就是把敲简单的业务代码这项工作,换成了其他更为艰难的任务。
当然,话再说回主题,既然目前无法在项目中用到各种新技术,目前的
CRUD
无法给自己带来技术成长,那我们要做的就是:在有限的空间内做到“无限”的发展,其实就算最普通的业务也能玩出不同花样,业务的增删改查想要做好也并不容易,比如怎样才能让代码更整洁、能否让程序拓展性更好?如何才能让代码跑的更快.....,动才能改变,抱怨再多也改变不了自身。
4.2、牢记谦虚戒骄,人外有人天外有天
这个道理应该是本次经历中,带给我感悟最深的一条,作为技术人觉得自己牛可以,但千万不要骄傲,也不要在面试中、同事交谈中、群聊讨论中.....表露出来,因为永远会有人比你更厉害,不要为了虚荣心去刻意“攀比”,否则最终倒霉的还是自己,举个很常见的案例:
如果当过面试官的小伙伴应该都遇到过一种情况,也就是候选者在面试时有些刻意装逼,这样的候选者在面试时,往往都会遭到面试官的无情打压,最简单的做法就是连环炮问法,从基础入门问源码原理,从
API
调用问到操作系统实现.....直到最后被问到哑口无言。
拥有自信是好事,但千万不要自信过头,牢记谦虚戒骄,因为人外有人天外有天。比如我,原以为自己的技术已达巅峰造极,但经过这次面试后,发现自己理解的一些东西都是浮于表面的假象,看似驾轻就熟,实际上只是上层特性的搬砖工,学习和学会,压根是两码事!
4.3、再牛的技术也永远是为业务所服务
在IT
开发行业,其实有不少人抱着做纯技术开发的念想,至少我遇到过的不在少数,不想去重复做单纯的业务开发,但也请牢记:技术驱动业务,但技术也永远是为业务提供服务。
当然,想做纯技术开发也并非不行,但国内这样的人很少,或者说国内这样的岗位比较少,除开少数中间件开发、开源技术研发、基础平台开发等工作外,大多数岗位都需要和业务打交道,所以在学习新技术时也万万不要忘了业务,等你吃透某一行业的业务时,也许给你带来的好处会胜过技术的收益。