不敢開車的老司機

不敢開車的老司機

常說車開多了膽子會愈來愈小,寫代碼也是。其實不是老司機膽子小了,而是新手無知無畏罷了。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小時就搞定了。併發

但真的這麼簡單嗎?老司機一眼就看出了其中的各類問題!高併發

  1. 時區問題:咱們的 App 是一個國際化的 App,如何處理時區問題?性能

  2. 高併發問題:高併發的狀況下會不會出現一天簽到屢次的問題?如何解決?設計

  3. 性能問題:需求中須要知道連續簽到天數,按照這樣的表結構如何查詢才能最高效?

問題都列在這了,開始一個個解決吧。

時區問題

第一個要面臨的就是時區問題。

考慮不周的狀況下,不少人會直接用當地時間或者 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_idcheck_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-24check_in_date = 2016-10-25的時候都失敗了!

至此,解決了用戶換時區後屢次簽到的問題。

如何高效地運算連續簽到天數和今天是否已經簽到

當我面臨這個問題的時候,各類算法,數據結構浮如今我腦中。

這種需求最早想到的就是二分查找發。

查找的步驟大概是這樣的:

  1. 先搜索出某我的最近的10天的數據,大部分人不會連續簽到這麼久

  2. 在內存中判斷他是否連續簽到了,他今天有沒有簽到

  3. 若是這10的數據中有漏掉的天數,那麼就能夠直接返回他的連續簽到天數和今天是否已經簽到了

  4. 若是他這10天所有簽到了,那麼就要開始查找之前的數據了,這時不須要找到全部數據,只要 COUNT 記錄行數,對比一下天數就知道是否漏掉了

  5. 先找20天前的數據,若是簽到次數是20,那麼繼續找40天的數據,再找80天,以此類推。

  6. 直到發現,例如160天的簽到數據小於160,那麼說明他的連續簽到天數在80-160之間。

  7. 二分查找發開始了,先判斷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 日期前一個坑位是否已經被佔用,若是已經被佔用,那麼能夠直接寫入後一個坑位。

查詢邏輯

查詢邏輯其實就是剛纔插入邏輯的一部分。利用索引高效查詢,並且只要一條數據,就能夠知道全部信息,很是高效!

總結

至此,一個簡潔、高效、合理、無衝突的系統完成了。正是老司機的各類「怕」,造就了更安全的行車過程。

原文地址:http://www.dozer.cc/2016/10/localization-check-in.html

相關文章
相關標籤/搜索