@Autowired
private StringRedisTemplate redis;
@RequestMapping("/sendSmsVerification")
public ResultVO sendSmsVerification(String sign, String userId){
String userIdSMS = "SMS_" + userId;
String value = redis.opsForValue().get(userIdSMS);
if ("3".equals(value)) {
return new ResultVO(200, "短信调用次数已达上限,请在十分钟后重试...");
if ("".equals(value)) {
redis.opsForValue().set(lockKey, 1, 10, TimeUnit.SECONDS);
else {
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插入数据:
成功:代表前面没有重复的请求,当前请求可以执行。
失败:代表前面有相同请求已经插入过了,当前请求需要被丢弃。
③状态机方案:在表上多加一个状态字段,对于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如下:
update zz_user set user_name = "竹子爱熊猫", ... where user_id = 888;
update
zz_user
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开发行业,其实有不少人抱着做纯技术开发的念想,至少我遇到过的不在少数,不想去重复做单纯的业务开发,但也请牢记:技术驱动业务,但技术也永远是为业务提供服务。
当然,想做纯技术开发也并非不行,但国内这样的人很少,或者说国内这样的岗位比较少,除开少数中间件开发、开源技术研发、基础平台开发等工作外,大多数岗位都需要和业务打交道,所以在学习新技术时也万万不要忘了业务,等你吃透某一行业的业务时,也许给你带来的好处会胜过技术的收益。
4.4、最后的结语
到这里,本篇内容也就结束啦,其实我最初写技术文章的初衷,也就是单纯的想分享技术知识,因为我自身在学习的途中,遇到了很多不如意的事情,例如:
看到标题特别适合自己,但点进去发现内容根本不是自己想要的....
找到一篇优质文章后,仔细阅读收益颇多,但想要寻找后续时,发现压根没有.....
阅读一些经典的技术书籍时,总会碰到一些很难理解,或者很拗口的段落....
有时发现某篇文章,名字特别吸引人,结果点进去发现是引流广告....
......
正是由于上面的一些原因,促使我动了写技术文章的念头,迄今为止,个人发布的所有文章,基本上都已连载形式出现,力求完整、优质、易懂且内容对得住标题!从2019年开始动笔,到如今转眼已逝三个年头,但却只完成了大概4个专栏:
《JVM成神路》
《深入并发编程》
《程序员的网络编程》
《全解MySQL数据库》
四个专栏看起来并不多,更文总共才几十篇,但累计码字已有近百万,利用工作之余的闲暇时光,写出这些连载专栏,帮助了不少小伙伴成长,也收获了许多小伙伴的称赞。本想着在《数据库》、《网络》两个专栏更完后,转头去写《中间件》或《分布式》专栏:
毕竟这两个专栏,是早已欠给大家要还的债,但正在《全解MySQL数据库》专栏接近尾声,打算朝着新专栏动笔时,船长大佬找到了我:
考虑再三,我说在年后有想法写,但在年前会先更完之前断掉的《网络》专栏,但在此之后,自己脑海里一直在想,要写什么题材的小册合适呢?写技术类型吧,自认为水平不够,思索一段时间后,结合如今大环境下的氛围,最终决心写实用型题材的内容,目前规划如下(可能会调整):
预计大概会在四月份左右上线,名字应该叫《求职的锦囊妙计》,整体内容偏向于代码之外的实用软技能,定价也不会太高,估计在小几十上下。为此,在最后的尾巴上,也提前给自己做个小小的宣传,有想法的小伙伴到时候可以关注一下(但如果想着分钱不花,主打的就是一个陪伴,也是完全OK的,哈哈哈~)
小册《技术人求职指南》已上线,链接为:s.juejin.cn/ds/AA2avrh/
感兴趣的小伙伴可详细了解,《技术人求职指南》目录如下:
竹子爱熊猫
maven