追忆四年前:一段关于我被外企CTO用登录注册吊打的不堪往事

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
.cn 域名,1个 12个月
简介: 是的,诸位没有看错,这篇文章的要讲述的并不是我吊打面试官,而是一段我被面试官吊打的陈年往事,这段痛苦的记忆在我脑海中长久不衰,也是一个我内心曾多次不愿面对的事实,各位看官可以准备好一小把瓜子,听我将这则旧事缓缓道来~

引言

   是的,诸位没有看错,这篇文章的要讲述的并不是我吊打面试官,而是一段我被面试官吊打的陈年往事,这段痛苦的记忆在我脑海中长久不衰,也是一个我内心曾多次不愿面对的事实,各位看官可以准备好一小把瓜子,听我将这则旧事缓缓道来~

写这篇文章的缘由是因为年前有不少小伙伴建议我写个2022年终总结,但我自己比较排斥写总结这类的,不过在脑海里回想近几年的技术生涯时,突然想起了这起有趣的事件,因而将其稍做分享,希望诸君认真读完本文之后,也能够给大家带来一些思考!

其实我与诸位一样,几年前的我排斥、甚至厌恶学习,毕竟知识从脑子过一遍后,一点也留不住的感觉我并不喜欢。但也是由于这次经历,才让我痛改前非,从一个不爱思考的“小码农”,变成了如今的“码农Plus”,好戏开场!

因为近期工作较忙的原因,其实这篇文章,是从去年最后一天,也就是20221230日开始,一直写到现在才完成的文章,标题也被我从原本的「追忆三年前」改成了「追忆四年前」,哈哈哈~

一、“被吊打事件”的前因后果

   在正式谈及这次“被吊打事件”之前,首先来聊一聊此次事件的前因后果,事情的起因源自于我太过自信,刚毕业的那段时间,本故事的主人公,也就是我,经过一些特殊手段,成功入职了一家从事教育软件开发的小企业。当然,你要问我什么样的特殊手段能让刚毕业的我,无需面试就进入了一家软件企业,那就是大名鼎鼎的面试秘法——走后门

因为我的一位亲戚,在这家企业担任级别不算低的“高管”,因此我靠常人不能及的手段成功入职,没错!俺是一个妥妥的关系户,也正式由于这个原因,所以入职后的工作任务并不算重,饭点前、下班前,冲在第一个的永远是我,毕竟实力摆在这,不嚣张点简直对不住我的身份,哈哈哈~

总之在入职第一份工作的时光中,我大致算整个研发部门中最轻松的那个,大致与沸点摸鱼区那群家伙的工作量类似[狗头],因为工作轻松,所以给了我不少摸鱼学习时间,也正是通过这些时间,我在忙完工作之余的时间内,自身也额外学习了不少Java技术。

当时我的技术大致是什么水平呢?这里我从招聘软件上将我当初的简历信息摘过来了,如下:

002.png

2019年的时候,我当时的简历是这样的,但凡我当初听到过的主流技术,基本上都去做了相关学习,并且一半以上在开发中都用过,因为当时的研发模式属于低代码定制开发,因此核心平台的功能代码中,涉及到的新技术也蛮多,所以当初我有着迷之自信,自认为技术达到了 很牛逼 的程度。

正是由于当时这份简历,给予了自己莫大的自信,再加上成也萧何败也萧何,因为关系户的原因,我入职额外轻松,但也正因为是关系户,所以极大程度上限制了自己的成长空间,也就是抹不开面子去提涨薪,因此最终我做出了一个决定:“大丈夫生于天地之间,岂能郁郁久居人下”

没错,当时的我毅然决然的“提桶跑路”了!提出辞职之后,在诸多的劝阻中,头也不回的卷铺盖走人,没有别的原因,完全归咎于个人对自己技术的自信!当时跑回了老家玩了一段时间后,想着男子汉大丈夫,是时候该有一番作为了!接着我去到了距离老家最近的省会城市,从此踏上了额外自信的面试之旅(上份工作不在老家的省份,这也是离职原因之一,玩心重,朋友都不在身边~)

从这段回忆中,大家应该能够感受出我当时的心态,用一个词去形容特别恰当,也就是“年少轻狂”,沉浸在自己的认知中,换当时的心理,如果非要找一个字来形容的话,那就是“我技术很屌”!哈哈哈,现在想起来感觉有些许幼稚,但当初的我确实就是这个心态,因自认为的技术飞速提升,造就了当时内心十分膨胀的我。

二、“被吊打事件”的正戏开场

003.png

   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、第二问:注册时的敏感词检测

技术总监:你在做注册业务的时候,有没有考虑过,如若用户填写的「昵称/用户名」涵盖敏感信息怎么办呢?比如填写的昵称存在传播色情、违反政策规定、存在侮辱性含义等情况。

「沉默下来思考了几十秒,内心OSWC,我还真没想过这块问题」

   对于这块问题,当时在开发时并未考虑完全,因为这个平台属于定制化开发的,所以用户注册量也不算太大,因此在设计时也没往这块多想。

技术总监:没关系,那假设现在我让你去解决这个问题,你会如何下手呢?

「当时的我,因为做的都是一些简单的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次,这种方案存在明显的临界限制问题。

下面重点聊一聊滑动时间窗口,这种方案是解决临界问题而被提出的,但对于滑动窗口的概念有些不好理解,所以先上一副逻辑图,如下:

005.png

在上图中,整个用虚红线圈出来的代表一个时间窗口,以上述例子来说,一个窗口的大小为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?来看下图:

006.png

看上图中给出的案例,因为目前的时间窗口大小是600s,而199s~203s显然处于同一个时间窗口范围内,但随着窗口向后滑动,这里依旧会出现临界问题,也就是在一个窗口范围内,同样会出现打破调用次数上限的情况,那这种情况下又该如何解决呢?其实答案很简单,把一个窗口的格子单位调小即可。

比如直接将每一格的单位大小从200s调整为1s,此时每过一秒钟,窗口就会向后滑动一格,等到100s秒过后,窗口会向后滑动100格,此时窗口的区间范围是101~700s,这就将199~203s这个范围包含了进去,因此上述情况自然就不会出现!

经过上述分析由此可以得出一条准则:当滑动窗口的格子划分的单位越小,整个窗口中的格子数量会越多,滑动窗口的向后移动就越平滑,限流的统计就会越精确

3.3.3、令牌桶限流方案

前面简单聊完了时间窗口限流方案后,接着再来聊一聊大名鼎鼎的令牌桶限流方案,令牌桶算法是一种类似于“池化”思想的产物,算法的大体过程如下:

007.png

  • ①初始化令牌桶并设置最大令牌数,当桶内的令牌达到阈值时,新添加的令牌会被拒绝或丢弃。
  • ②根据限流大小,启动一条线程,并按照一定速率向令牌桶中不断添加新的令牌。
  • ③任何处于「限流范围」内的请求,都需要先获取到一个可用令牌,然后才会被处理。
  • ④当一个请求获取到可用令牌后,才会真正执行业务逻辑,执行完成后会将此令牌从桶内移除。
  • ⑤令牌桶除开有最大令牌数外,也会有最小令牌数,当桶内令牌数小于最小阈值时,处理完请求并不会移除令牌,而是会将令牌还给令牌桶。

对于令牌桶限流算法,理解起来并没有前面的滑动时间窗口复杂,但唯一要注意的是:当桶内的令牌被一个请求获取后,此时并不会立马从桶内移除,该令牌会依旧停留在桶内,只不过该令牌的状态会从可用状态变为不可用状态,也就是其他请求无法再获取该令牌,真正移除令牌的工作,会在业务逻辑执行完成之后才触发。

3.3.4、漏桶限流方案

漏桶限流和令牌桶限流都属于桶类型的算法,但漏桶算法更类似于MQ消息队列,其算法的执行示意图如下:

008.png

想要理解漏桶算法,咱们先来看看日常生活中的漏斗,比如现在我要用漏斗来给摩托车加油:

009.png

倒油时,我们可以用瓶子,也可以用桶子,也可以用加油枪.....,这也就意味着:漏斗上方的进油速率并不固定,但不管上方的进油速率如何,下方的漏斗出口,其速率确实固定的,无论上方进油多快,都不能影响下方的出油速率。

理解了日常生活中的漏斗后,接着再来看看前面的漏桶限流算法,请求会从漏桶上方进入,而服务端则只会按照固定速率去处理请求。此时思考一个问题:当请求进入的速率大于请求处理的速率,会发生什么情况呢

此时依旧回到用漏斗给摩托车加油的例子中,如果漏斗上方的倒油速度比较快,而由于漏斗的结构原因,下方的出口跟不上进油速度,此时漏斗中的油量会直线上升,直到超出漏斗的最大容量时,再进入漏斗的汽油会溢出。

而限流中的漏桶算法同样如此,请求进入的速率大于请求处理的速率时,多出来的请求会被放入桶中等待,当桶内阻塞等待的请求超过最大限制后,后续进入的请求会被丢弃或拒绝。

从上述的讲解中,诸位应该能够明显感受到漏桶算法的特点,即:宽进严出,该算法中不会限制请求进入的速率,但会限制请求处理的速率,一些对稳定性要求较高的系统,就可以采用该算法对系统进行限流。当然,如果熟悉MQ的小伙伴也能感受出:漏桶算法和MQ的削峰填谷有着异曲同工之妙,当系统峰值流量较高时,会将请求写入到MQ中,然后再由具体的业务服务,按照固定的速率拉取MQ中的消息进行处理

3.3.5、高并发限流算法小结

在前面共计提到了计数器、滑动窗口、令牌桶、漏桶这四种常规的限流方案,但要记住:并不存在一种适用于任何场景的限流算法,根据业务的需求不同,系统的关注面不同,应当采用不同的限流方案,没有所谓的最好!最后简单说一些成熟的限流实现:

  • Guava中的RateLimiter工具类:基于令牌桶实现的限流组件,并且对其进行了预热拓展。
  • Sentinel中的匀速排队限流策略:基于漏桶思想的限流策略,内部采用队列进行实现。
  • Nginxlimit_req_zone限流模块:基于漏桶思想的限流模块,实现网关层的限流控制。
  • ........

3.4、第四问:API接口的幂等性问题

技术总监:接下来我们再聊聊其他方面的可以吧?
技术总监:以目前的技术来说,任何用户在使用网络时,难免会存在延迟是不是?

   对的,这点我深有体会,尤其是在过年回老家的时候,由于山区的网络覆盖并不全面,所以在访问一个网站时,加载的速度会特别的慢。

技术总监:嗯呢,既然你也说了这个问题,那我再问你一个问题。
技术总监:如果一个用户在注册时,网络比较卡顿,所以提交注册后迟迟没有反应,因此他又连续点击了多次「注册」按钮,此时会发生什么情况呢?

「我沉思片刻回答道」:如果没有做任何限制,理论上会向服务端发出多次请求,如果数据库的表结构设计不合理,那么还会出现同一用户的注册信息,在用户表中被插入多次。

技术总监:说的不错,那请问你们当时是怎么处理呢?

   我们当时处理方案比较简单,首先在前端做了一定限制,也就是当用户首次点击了「注册」按钮后,「注册」按钮就会变成灰色,也就是用户再次点击时,并不会再次发送Post请求向后端提交表单数据。

技术总监:那如果用户看点击注册按钮后迟迟没反应,按F5刷新或浏览器的后退键,接着再次点了「注册」按钮怎么办?

「心里一颤,没想过啊!硬着头皮解释道」:对于此问题,我在做登录注册时并未考虑周全,未对这个问题进行思考。

但其实现在想来,解决的思想也比较简单,除开在原本将按钮变灰的基础上,再加上一个「重定向页面」即可,比如信息提交后就跳转下述这个界面:

010.png

这样做的好处在于:重定向操作发生后,当用户再次刷新网页,或者通过浏览器的回退键,回到原本的界面时,之前表单中填写的信息并不会保存。这样做的好处在于:用户想要再次点击注册按钮,就只能再次重新输入信息。

在用户网络比较卡顿的情况下,做了上述设计后,就只会出现两种情况:

  • ①用户上次点击「注册」按钮提交的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方案,这种方式到底是如何实现的呢?下面展开聊一聊,示意图如下:

011.png

  • ①当用户进入一个表单时,前端通过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会自动将当前「手机号、微信」产生绑定关系,后续可以两者中的任一方式登录。

第二种做法我在简书见过,当多个账号之间存在冲突时,将合并账号的权利交给用户自己,当用户选择保留某个账号时,其他账号都会被销毁,包括其他账号在平台上的所有数据也会彻底丢失。

第三种做法我在一些小的自建站见过,其实这是触发了平台的「未知操作」的补偿机制,由于用户在尝试绑定一个「已绑微信」,这种操作在程序后台无法识别,所以直接给出统一的提示,即:“请联系管理员进行申诉”,申诉后会由平台管理员,介入修改后台数据库进行处理。

第四种做法在游戏的用户管理中比较常见,以广为人知的「王者荣耀」举例说明,在登录界面可以选择通过微信登录游戏,而微信登录成功之后,会出现下述这个界面:
012.png

在这类游戏中,玩家可以自行选择分区,同一个微信账号支持在多个分区创建账号,这也就意味着一个第三方账号,可以与多个平台账号存在关联关系,当用户下次通过该微信账号登录时,用户可以自行选择具体的分区(具体要登录的平台账号)。

第五种做法属于最常见的做法,明确规则一个第三方账号,只能与一个平台账号存在绑定关系,当一个账号尝试绑定第三方账号时,如果检测到对应的第三方账号存在其他的绑定关系,就直接提示用户:“该第三方账号已被其他账号绑定,请手动解除绑定后重试”!

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实现单点登录的原理,如下图:
013.png

前提:
①当用户在访问任意子系统没有携带Token(Ticket)时,都会被重定向到独立部署的SSO认证中心。
②如果对应的用户在SSO服务中找不到登录凭证,最终会跳转登录页面,要求用户进行登录操作。

一次完整的单点登录过程:

  • ①用户未携带Ticket访问A系统的某个页面,被重定向到SSO服务。
  • ②用户未携带登录凭证访问SSO认证中心,被重定向到登录页面。
  • ③用户完成登录操作,在SSO域的Cookie中植入各种凭证,并再携带Code重定向到A系统的回调接口。
  • ④用户携带Code访问A系统,ASSO请求验证Code,有效则为A域颁发Ticket,并重定向到原网页。
  • ⑤用户携带Ticket访问A系统的原网页,ASSO请求校验Ticket,有效则执行具体的业务逻辑。
  • ⑥用户访问B系统的某个页面(此时无法携带A域的Ticket),被重定向到SSO服务。
  • ⑦用户携带SSO-Cookie访问SSO,该用户的登录凭证校验成功,携带Code重定向到B系统的回调。
  • ⑧用户携带Code访问B系统,BSSO请求验证Code,有效则为B域颁发Ticket,并重定向到原网页。
  • ⑨用户携带Ticket访问B系统的原网页,BSSO请求校验Ticket,有效则执行具体的业务逻辑。

为什么可以通过CodeTicket呢?利用OAuth2.0的四种授权方式之一:授权码来实现。
为什么要用CodeTicket呢?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开发行业,其实有不少人抱着做纯技术开发的念想,至少我遇到过的不在少数,不想去重复做单纯的业务开发,但也请牢记:技术驱动业务,但技术也永远是为业务提供服务。
   当然,想做纯技术开发也并非不行,但国内这样的人很少,或者说国内这样的岗位比较少,除开少数中间件开发、开源技术研发、基础平台开发等工作外,大多数岗位都需要和业务打交道,所以在学习新技术时也万万不要忘了业务,等你吃透某一行业的业务时,也许给你带来的好处会胜过技术的收益。

相关文章
|
人工智能 Java 大数据
CSDN:2020年度CSDN博客之星评选竞赛——180号【一个处女座的程序猿】,感谢您,投上的宝贵一票,感谢!感恩!
CSDN:2020年度CSDN博客之星评选竞赛——180号【一个处女座的程序猿】,感谢您,投上的宝贵一票,感谢!感恩!
CSDN:2020年度CSDN博客之星评选竞赛——180号【一个处女座的程序猿】,感谢您,投上的宝贵一票,感谢!感恩!
|
Java 程序员
一个程序员的国庆血泪相亲史,惨败而归…
没错,这个程序员就是博主我。 伴随着国庆往返的大军,博主我也踏上了幸福的回家之路。
|
架构师 程序员
蚂蚁金服CTO鲁肃:支付宝成就了我,我做了很多“拧螺丝”的事儿
他被誉为支付宝技术平台的奠基人之一,但是他却说“这还不是我心中最完美的架构”;他曾是支付宝史上最危机关头——停机发布 17 小时的救火大队长,但是在他看来,只是做了很多”拧螺丝“的事情。
3138 0
|
人工智能 大数据 程序员
饿了么CTO张雪峰:允许90后的技术人员“浮躁“一点
编者按:今年4月,饿了么正式加入了阿里新零售战队,进一步加速其在本地生活市场的扩张速度。在创业9年的时间中,饿了么在外卖领域经历了真正的“从0到1”,尤其是在外卖平台的技术升级方面,越过了一个又一个的无人区。
8631 0
|
机器学习/深度学习 Java 计算机视觉
小长假归来,8本最新上架新书在此等候
小长假一转眼就过去了,是不是感觉很短暂,没关系,十一马上又要一喽!!!假期一波接一波,开不开心。长假归来,异步社区上架了8本新书,你有没有关注到,让小编一一道来!
2755 0