不敢开车的老司机

不敢开车的老司机

常说车开多了胆子会愈来愈小,写代码也是。其实不是老司机胆子小了,而是新手无知无畏罢了。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

相关文章
相关标签/搜索