不敢开车的老司机
常说车开多了胆子会越来越小,写代码也是。其实不是老司机胆子小了,而是新手无知无畏罢了。
最近一个很简单的功能,我做了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 日期前一个坑位是否已经被占用,如果已经被占用,那么可以直接写入后一个坑位。
查询逻辑
查询逻辑其实就是刚才插入逻辑的一部分。利用索引高效查询,而且只要一条数据,就可以知道所有信息,非常高效!
总结
至此,一个简洁、高效、合理、无冲突的系统完成了。正是老司机的各种“怕”,造就了更安全的行车过程。