常說車開多了膽子會愈來愈小,寫代碼也是。其實不是老司機膽子小了,而是新手無知無畏罷了。html
最近一個很簡單的功能,我作了2-3天,要是在我剛畢業的是時候把這個任務交給我,啪啪啪,不是我吹牛,2-3小時我就搞定了!算法
直接看產出的結果可能沒以爲怎麼樣,甚至還會以爲這麼作不對,但我以爲其中的思考過程仍是很是有價值的,因此想在這記錄下來。數據庫
這個任務的需求簡單到一句話就能夠描述了:作一個每日簽到系統,連續簽到會有額外的積分獎勵。安全
這功能,見的太多了吧?我分分鐘就把表結構和 API 設計好了!服務器
+------------------------+------------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +------------------------+------------------+------+-----+---------+-------+ | user_id | int(10) unsigned | NO | PRI | NULL | | | check_in_time | timestamp | NO | PRI | NULL | | +------------------------+------------------+------+-----+---------+-------+
API 就不用說了吧?太簡單了,每次簽到的時候檢查一下當天有沒有簽到過就好了。數據結構
你看吧,我就說交給剛畢業的我,2-3小時就搞定了。併發
但真的這麼簡單嗎?老司機一眼就看出了其中的各類問題!高併發
時區問題:咱們的 App 是一個國際化的 App,如何處理時區問題?性能
高併發問題:高併發的狀況下會不會出現一天簽到屢次的問題?如何解決?設計
性能問題:需求中須要知道連續簽到天數,按照這樣的表結構如何查詢才能最高效?
問題都列在這了,開始一個個解決吧。
第一個要面臨的就是時區問題。
考慮不周的狀況下,不少人會直接用當地時間或者 UTC 時間來解決。
由於若是在國內作開發,可能你的系統只要處理中國標準時間就夠了,徹底不須要考慮時區問題。
遙想當年作系統的時候,數據庫裏存的所有是本地時間… But it works well!
在國際化的背景下,簽到的體驗應該是怎麼樣的?
若是一我的一生呆在一個地方,那麼他每日簽到的時候就應該用他的當地時間做爲節點。天天過午夜0點的時候,就能夠再次簽到了。
解決這點很簡單啊!咱們在簽到的接口中,加入了timezone
參數。timezone
的最小顆粒度是分鐘,因此咱們的參數是分鐘級別的。
local_now = utc_now + timezone * 60 local_today_start = local_now - local_now % (24 * 60 * 60) local_today_start_in_utc = local_today_start - timezone * 60
上述代碼會根據用戶傳入的時區,找到他的時區中對應的一天開始時間。
而後 SQL 語句能夠是這樣的:
SELECT * FROM check_in WHERE user_id = {user_id} AND check_in_time >= {local_today_start_in_utc}
這樣就能夠判斷這個用戶是否是在「今天」簽到過了。
上面的方案看似完美,可是眼尖的老司機們又發現了問題!
utc_now
是系統時間,用戶沒法篡改,但timezone
是用戶傳上來的,它徹底能夠僞造請求或者手動修改手機時區,服務器根本不可能判斷這個參數是否真實。
那麼就會出現以下場景:
用戶timezone
是+480
,他在當地時間2016-10-25 00:00:01
簽到,至關於在在 UTC 時間2016-10-24 16:00:01
簽到。
此時,用戶強制修改本身的時區爲+540
,在當地時間2016-10-25 00:01:02
簽到,至關於在在 UTC 時間2016-10-24 17:00:02
簽到。
根據上面的設計,用戶是能夠簽到成功的,他能夠利用這個方式,天天簽到屢次,這樣也就能夠得到大量的積分。系通通計連續簽到天數的時候,也會出現錯亂。
不只如此,若是用戶惡意快速請求接口,2次請求同時判斷當天無簽到,而後又同時寫入了數據,也會出現重複簽到問題。
如何解決這兩個問題呢?
| UTC 10-25 | UTC 10-26 | UTC 10-27 | ---+-----------+-----------+-----------+--- | LOC 10-26 |
先畫個時間軸看看,假設用戶的時區是 +12,那麼他比 UTC 時間早了12個小時。
此時,他的一天中可能會對應到 UTC 時間的10月25日,也可能會對應到UTC時間的10月26日。
想要避免他重複簽到,最理想的就是利用數據庫惟一鍵索引或者是主鍵。那這裏的聯合主鍵其實就是用戶 ID 和 UTC 日期了。
UTC 日期計算方法就是:int(utc_now/24/60/60)
,也就是說,當地日期可能會跨2個UTC日期,那麼默認取前一個。
表結構也要改一下:
+------------------------+------------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +------------------------+------------------+------+-----+---------+-------+ | user_id | int(10) unsigned | NO | PRI | NULL | | | check_in_date | timestamp | NO | PRI | NULL | | | check_in_time | timestamp | NO | | NULL | | +------------------------+------------------+------+-----+---------+-------+
這裏加了一個check_in_date
字段,而且,把user_id
和check_in_date
作成了聯合主鍵。
這樣不管用戶怎麼高併發,配合INSERT IGNORE
語句,並在每次執行的時候檢查影響行數,就能夠知道是否插入成功了。
插入成功後再去增長積分就能夠了。
等等,時區問題是否是漏了?
剛纔說,若是一個用戶瞬間到了另外一個地方,時區變了一點點,理論上他是能夠再度過一次0點的。
當地日期可能會跨2個 UTC 日期,那麼默認取前一個。若是,發現他垮了時區,在當前時區下的「今天」沒簽到過,那麼容許他再一次簽到,寫入數據庫的就是跨2個 UTC 日期的後一個。
直接說太生澀,舉個例子:
用戶timezone
是+480
,他在當地時間2016-10-25 00:00:01
簽到,至關於在在 UTC 時間2016-10-24 16:00:01
簽到。
寫入的數據是這樣的:
+---------+---------------+---------------------+ | user_id | check_in_date | check_in_time | +---------+---------------+---------------------+ | 1 | 2016-10-24 | 2016-10-24 16:00:01 | +---------+---------------+---------------------+
接下來,他改時區了:
用戶強制修改本身的時區爲+540
,在當地時間2016-10-25 00:01:02
簽到,至關於在在 UTC 時間2016-10-24 17:00:02
簽到。
根據這條 SQL 語句,查詢到的數據是0條:
SELECT * FROM check_in WHERE user_id = 1 AND check_in_time >= '2016-10-24 17:00:00'
也就是說他能夠簽到,先嚐試這樣的數據:
+---------+---------------+---------------------+ | user_id | check_in_date | check_in_time | +---------+---------------+---------------------+ | 1 | 2016-10-24 | 2016-10-24 16:00:01 | | 1 | 2016-10-24 | 2016-10-24 17:00:02 | +---------+---------------+---------------------+
很明顯,主鍵衝突了,第二條數據是寫不進去的,那麼此時就嘗試check_in_date
加一天:
+---------+---------------+---------------------+ | user_id | check_in_date | check_in_time | +---------+---------------+---------------------+ | 1 | 2016-10-24 | 2016-10-24 16:00:01 | | 1 | 2016-10-25 | 2016-10-24 17:00:02 | +---------+---------------+---------------------+
再接下來,厲害了 Word 哥,他又改了時區:
用戶強制修改本身的時區爲+600
,在當地時間2016-10-25 00:02:03
簽到,至關於在在 UTC 時間2016-10-24 18:00:03
簽到。
此時根據,「今天」他仍是沒有簽到數據,但當他嘗試插入check_in_date = 2016-10-24
和check_in_date = 2016-10-25
的時候都失敗了!
至此,解決了用戶換時區後屢次簽到的問題。
當我面臨這個問題的時候,各類算法,數據結構浮如今我腦中。
這種需求最早想到的就是二分查找發。
查找的步驟大概是這樣的:
先搜索出某我的最近的10天的數據,大部分人不會連續簽到這麼久
在內存中判斷他是否連續簽到了,他今天有沒有簽到
若是這10的數據中有漏掉的天數,那麼就能夠直接返回他的連續簽到天數和今天是否已經簽到了
若是他這10天所有簽到了,那麼就要開始查找之前的數據了,這時不須要找到全部數據,只要 COUNT 記錄行數,對比一下天數就知道是否漏掉了
先找20天前的數據,若是簽到次數是20,那麼繼續找40天的數據,再找80天,以此類推。
直到發現,例如160天的簽到數據小於160,那麼說明他的連續簽到天數在80-160之間。
二分查找發開始了,先判斷120天的簽到數據,若是是齊的,那麼找120-160以前,一次類推最後會確認連續簽到天數
當這段代碼跑起來的時候,我不經爲本身鼓起了掌!?????
而後,我還爲此寫了詳細的註釋:
# check_offset_upper_bound = [ # check_offset_lower_bound = ] # query_offset = ^ # # Init status # ^ ] # ?|?|?|?|?|?|?|?|?|?|?|?|?|*|*|*|*|*|*| # # # All check in # ^ ] # ?|?|?|?|?|?|?|*|*|*|*|*|*|*|*|*|*|*|*| # # # Not all check in # [ ^ ] # x|x|?|?|?|?|?|*|*|*|*|*|*|*|*|*|*|*|*| # # # All check in # [ ^ ] # x|x|?|?|*|*|*|*|*|*|*|*|*|*|*|*|*|*|*| # # # All check in # [ ^ ] # x|x|?|*|*|*|*|*|*|*|*|*|*|*|*|*|*|*|*| # # # Not all check in # [ ] # x|x|x|*|*|*|*|*|*|*|*|*|*|*|*|*|*|*|*|
感受本身就要走向人生巔峯了!
正當我沾沾自喜的時候,仍是感受有點不太對勁,這段算法雖然高效,可是是否能夠利用空間換時間,把這個數據存下來,再次提升效率呢?
最後,想到了最終版的高效方案。
+------------------------+------------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +------------------------+------------------+------+-----+---------+-------+ | user_id | int(10) unsigned | NO | PRI | NULL | | | check_in_date | timestamp | NO | PRI | NULL | | | check_in_time | timestamp | NO | | NULL | | | consecutive_check_days | int(10) unsigned | NO | | NULL | | +------------------------+------------------+------+-----+---------+-------+
假設數據庫裏有以下數據:
+---------+---------------+---------------------+-----------------------+ | user_id | check_in_date | check_in_time | consecutive_check_days| +---------+---------------+---------------------+-----------------------+ | 1 | 2016-10-24 | 2016-10-24 16:00:01 | 1 | | 1 | 2016-10-25 | 2016-10-24 17:00:02 | 2 | +---------+---------------+---------------------+-----------------------+
假設如今是2016年10月26日,我須要查詢今天是否可簽到,和以前的連續簽到天數。
查詢語句是:
SELECT * FROM check_in WHERE user_id = 1 AND check_in_date >= '2016-10-25' ORDER BY check_in_date DESC LIMIT 1;
若是一條數據都沒,那麼返回今天可簽到,以前連續簽到天數0
若是返回數據check_in_time
是「今天」,而且check_in_date
已經把以前提到的兩個 UTC 日期坑位佔滿,那麼今天就不能夠簽到了,可是以前的連續簽到天數就是2
。
相反,若是數據表示能夠簽到,那麼這裏就能夠簽到,簽到邏輯和上面略有不一樣。
首先是多了consecutive_check_days
,此時只要寫入2+1
便可。而後是根據查詢到的數據,能夠判斷出 UTC 日期前一個坑位是否已經被佔用,若是已經被佔用,那麼能夠直接寫入後一個坑位。
查詢邏輯其實就是剛纔插入邏輯的一部分。利用索引高效查詢,並且只要一條數據,就能夠知道全部信息,很是高效!
至此,一個簡潔、高效、合理、無衝突的系統完成了。正是老司機的各類「怕」,造就了更安全的行車過程。