一种基于存储和索引分离的数据库分表方案

日常的工作中,当一个数据表的数据过多少,我们就需要拆表甚至拆库了,我们这篇文章只说拆表。常见的拆表有两种:

  1. hash拆表,按某个或多个字段进行hash映射到具体一个表里,比如按user_id取模。
  2. range拆表,按某个或多个字段进行区间分表,比如按id范围分表,按时间分表。

这两种各有利弊

  1. hash的好处是如果刚好查询字段契合了分表字段,那么查询非常容易。缺点就是扩容,面对突发的数据,单表很容易满,满了后需要建更多的表,然后重新rehash。如果查询的字段跟分表的字段不同,那就悲剧了,得每个表查一次。
  2. range好处是扩展性好,哪怕数据暴增,可以按某个字段值作为截断,此字段值以后的按新分表方式,以前的还是老方法,如果按ID区间分表,那么再多表都不怕。缺点就是查询的时候,可能要查很多表。

数据的存储

刚好我有个需求,需要存很多的数据,而且还要保证缓存的命中率和一致性,所以决定模仿倒排索引的原理,采用存储和索引分离的策略。

mysql> show fields from t_sc_schedule_201911;
+-------------+---------------------+------+-----+---------+----------------+
| Field       | Type                | Null | Key | Default | Extra          |
+-------------+---------------------+------+-----+---------+----------------+
| id          | bigint(18) unsigned | NO   | PRI | NULL    | auto_increment |
| pid         | bigint(20) unsigned | NO   |     | 0       |                |
| tid         | bigint(18) unsigned | NO   | MUL | 0       |                |
| eid         | bigint(20) unsigned | NO   |     | 0       |                |
| uid         | int(11) unsigned    | NO   | MUL | 0       |                |
| aid         | bigint(20) unsigned | NO   | MUL | 0       |                |
| ctime       | int(11) unsigned    | NO   |     | 0       |                |
| begin_time  | int(11) unsigned    | NO   |     | 0       |                |
| finish_time | int(11) unsigned    | NO   |     | 0       |                |
| alert_state | tinyint(3) unsigned | NO   |     | 0       |                |
| alert_time  | int(11) unsigned    | NO   |     | 0       |                |
| delay_time  | int(11) unsigned    | NO   |     | 0       |                |
| resp_state  | tinyint(2) unsigned | NO   |     | 0       |                |
| resp_reason | varchar(255)        | NO   |     |         |                |
| resp_time   | int(11) unsigned    | NO   |     | 0       |                |
| is_del      | tinyint(1) unsigned | NO   |     | 0       |                |
| mtime       | int(10) unsigned    | NO   |     | 0       |                |
+-------------+---------------------+------+-----+---------+----------------+
17 rows in set (0.00 sec)

索引的存储

因为这个表的数据很大,而且会一直递增下去,所以决定时间(begin_time)分表,按天,按周,按月都行。

但是,在查询的时候,条件很特殊,需要按uid和经历时间去查询(经历时间指的是只要包含就算,比如一条记录是1月1号到10月1号,那么你查询的区间只要与9月1号到10月1号有1秒的交集就算),因为begin_time和finish_time的跨度不确定,所以我查询的时候需要每张表都查一次。而为了保证读取效率,还需要设计索引系统。索引系统有两点需要注意“”

  1. 其中主键id不是数据库自动生成的,而是通过算法算出来的,其中包含了begin_time,所以只要给出id,就能解析出begin_time,继而直接算出在哪张表。
  2. 在数据更新的时候,索引表也要更新。

我设计的索引表结构如下:

mysql> show fields from t_sc_index_202004;
+-------------+---------------------+------+-----+---------+----------------+
| Field       | Type                | Null | Key | Default | Extra          |
+-------------+---------------------+------+-----+---------+----------------+
| id          | int(11) unsigned    | NO   | PRI | NULL    | auto_increment |
| uid         | int(11) unsigned    | NO   | MUL | 0       |                |
| sid         | bigint(19) unsigned | NO   |     | 0       |                |
| begin_time  | int(11) unsigned    | NO   |     | 0       |                |
| finish_time | int(11) unsigned    | NO   |     | 0       |                |
| ctime       | int(11) unsigned    | NO   |     | 0       |                |
+-------------+---------------------+------+-----+---------+----------------+
6 rows in set (0.00 sec)

(图中的sid就是对应的第一张表的id)

索引表也是按月拆分的,实际可以按你的需求来分。我在插入一条数据的时候,会算出begin_time和finish_time所在的月份,然后在它所包含的每个月的表里都插一条记录。在查询的时候,先去索引表查出sid,然后根据sid算出数据所在表,然后直接查出数据。我这里只列举了一种维度的索引,实际上有多少种查询,就可以设计多少种索引表。

缓存

实际上,索引表并不一定需要存在数据库,其实可以直接存在缓存里。但是因为我们这边的redis不是高可用的,所以我只能把redis用作cache而不能当做db。缓存的时候我用了redis的zset来存index,key为${prefix}:$uid:$ym,value为score:finish_time,val:sid 其中sid是用pack转换成了较短的字符串。而之所以只需要一个key就能存,是因为我的sid是根据begin_time算出来的,所以有了sid就等于有了begin_time,只需要再存上finish_time即可。在我们查出sid后,就可以去数据表查出具体的数据了。

更新

说到这里,我们在用数据库的索引的时候,从来不用关心索引的更新,因为数据库已经帮我们做了,但是如果我们自己来实现索引,遇到数据更新就很麻烦。在这里,我的策略是,索引只插入,不修改或删除,也就是说,只要数据表有数据插入,修改,删除,那么索引表就会插入一条新的记录,同事数据表数据只要变更,mtime字段就一定会变。在查询的时候,大概的步骤如下:

  1. 先查出一批sid
  2. 对这批sid去除重复,相同的取ctime最大的。
  3. 根据sid去数据表查出数据,同时对比索引表的ctime和数据表的mtime,如果不一致,则舍去。同时为了提高缓存的命中率,数据表是标记删除的,也就是说删除只是一种状态,如果该记录是删除状态一样要舍去。

对于索引表的废弃数据,完全没有必要实时删除,定期跑个脚本清理即可,或者在过滤的时候,将舍弃的索引数据扔到队列,由异步脚本去监控队列删除。更新数据的时候,也只需要刷新以id为key的那个缓存就行了。这样缓存的命中率就大大提高了。

最后

说了半天,我突然发现,我其实就是把倒排索引实现了一遍......我这里也只是提供一种思路,实际的情况需要开发根据自己的实际情况去设计。