不敢开车的老司机

常说车开多了胆子会越来越小,写代码也是。其实不是老司机胆子小了,而是新手无知无畏罢了。

最近一个很简单的功能,我做了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    |       |
+------------------------+------------------+------+-----+---------+-------+

其中check_in_time记录的是 UTC 时间。

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-60,他在当地时间2018-11-11 01:00:00签到,相当于在在 UTC 时间2018-11-11 02:00:00签到。

此时,用户强制修改自己的时区为+120,在当地时间2018-11-1 01:00:00签到,相当于在在 UTC 时间2018-11-11 23:00:00签到。

 

根据上面的设计,用户是可以签到成功的,他可以利用这个方式,每天签到多次,这样也就可以获得大量的积分。系统统计连续签到天数的时候,也会出现错乱。

不仅如此,如果用户恶意快速请求接口,2次请求同时判断当天无签到,然后又同时写入了数据,也会出现重复签到问题。

 

少用事务,多用唯一键索引

如何解决这两个问题呢?

   | UTC 10-25 | UTC 10-26 | UTC 10-27 |
---+-----------+-----------+-----------+---
         | LOC 10-26 |

先画个时间轴看看,假设用户的时区是 +12,那么他比 UTC 时间早了12个小时。

此时,他的一天中可能会对应到 UTC 时间的10月25日,也可能会对应到UTC时间的10月26日。

想要避免他重复签到,最理想的就是利用数据库唯一键索引或者是主键。那这里的联合主键其实就是用户 ID 和 UTC 日期了。

本地日期可能会跨2个UTC日期,那么默认取前一个,UTC 日期计算方法就是:int(local_today_start_in_utc/24/60/60)

 

表结构也要改一下:

+------------------------+------------------+------+-----+---------+-------+
| 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语句,并在每次执行的时候检查影响行数,就可以知道是否插入成功了。

插入成功后再去增加积分就可以了。

 

忘了时区问题?

等等,时区问题是不是漏了?

刚才说,如果一个用户瞬间到了另一个地方,时区变了一点点,理论上他是可以瞬间到达“第二天”的。

但这个“第二天”的check_in_date和上次签到的check_in_date可能是一样的。

这里就产生矛盾了,从用户体验上讲,他提前进入了第二天,那么就应该可以签到,这时候应该怎么解决?

 

直接说太生涩,举个例子:

用户timezone-60,他在当地时间2018-11-11 01:00:00签到,相当于在在 UTC 时间2018-11-11 02:00:00签到。

写入的数据是这样的:

+---------+---------------+---------------------+
| user_id | check_in_date | check_in_time       |
+---------+---------------+---------------------+
| 1       | 2018-11-11    | 2018-11-11 02:00:00 |
+---------+---------------+---------------------+

接下来,他改时区了:

用户强制修改自己的时区为+120,在当地时间2018-11-12 01:00:00签到,相当于在在 UTC 时间2018-11-11 23:00:00签到。

此时,根据check_in_date算法,算出来的check_in_date也是2018-11-11。如果不做特殊时区处理,这个人是无法再次签到的。

但是从逻辑上来讲,他跨越了时区,他又来到了新的一天,那么他应该可以签到的。

根据之前的check_in_date的算法,我们默认取的是前一个 UTC 日期,在这种情况下,我们允许他借一天。

所以我们把他最后的签到记录取出来:

+---------+---------------+---------------------+
| user_id | check_in_date | check_in_time       |
+---------+---------------+---------------------+
| 1       | 2018-11-11    | 2018-11-11 02:00:00 |
+---------+---------------+---------------------+

如果新的check_in_date本来就更大,那么就直接让他签到了。

但在这种场景中,check_in_date是一样的,所以如果check_in_date是一样的话,就利用他新的时区,计算check_in_time和当前时间是否是同一天。

如果算出来他上次签到的本地日期和新签到的本地日期不一样,那么就允许他借一天签到。

所以插入数据库的是check_in_time + 1

+---------+---------------+---------------------+
| user_id | check_in_date | check_in_time       |
+---------+---------------+---------------------+
| 1       | 2018-11-11    | 2018-11-11 02:00:00 | # Local Time: 2018-11-11 01:00:00
| 1       | 2018-11-12    | 2018-11-11 23:00:00 | # Local Time: 2018-11-12 01:00:00
+---------+---------------+---------------------+

但是,如果他继续改时区,因为我们只允许借一天,所以后续就无法再这样操作了。

那么他借的这一天什么时候会还回去呢?如果他一直在新的时区签到,那么一直会占用借来的这一格。

哪天他回到了原来的时区,他时光倒流了,他借来的那一天也该归还了。回到原来时区以后,就算签到漏掉了一天,系统也不会认为他漏掉了。

 

如何高效地运算连续签到天数和今天是否已经签到

当我面临这个问题的时候,各种算法,数据结构浮现在我脑中。

这种需求最先想到的就是二分查找法。

查找的步骤大概是这样的:

  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 日期前一个坑位是否已经被占用,如果已经被占用,那么可以直接写入后一个坑位。

 

查询逻辑

查询逻辑其实就是刚才插入逻辑的一部分。利用索引高效查询,而且只要一条数据,就可以知道所有信息,非常高效!

 

总结

至此,一个简洁、高效、合理、无冲突的系统完成了。正是老司机的各种“怕”,造就了更安全的行车过程。

本作品由 Dozer 创作,采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可。