是的,諸位沒有看錯,這篇文章的要講述的并不是我吊打面試官,而是一段我被面試官吊打的陳年往事,這段痛苦的記憶在我腦海中長久不衰,也是一個我内心曾多次不願面對的事實,各位看官可以準備好一小把瓜子,聽我将這則舊事緩緩道來~
寫這篇文章的緣由是因為年前有不少小夥伴建議我寫個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開發行業,其實有不少人抱着做純技術開發的念想,至少我遇到過的不在少數,不想去重複做單純的業務開發,但也請牢記:技術驅動業務,但技術也永遠是為業務提供服務。
當然,想做純技術開發也并非不行,但國内這樣的人很少,或者說國内這樣的崗位比較少,除開少數中間件開發、開源技術研發、基礎平台開發等工作外,大多數崗位都需要和業務打交道,是以在學習新技術時也萬萬不要忘了業務,等你吃透某一行業的業務時,也許給你帶來的好處會勝過技術的收益。
作者:竹子愛熊貓
連結:https://juejin.cn/post/7204715616283836473