不敢开车的老司机

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

最近一个很简单的功能,我做了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 日期前一个坑位是否已经被占用,如果已经被占用,那么可以直接写入后一个坑位。

 

查询逻辑

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

 

总结

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

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