在一般的工程项目中,对于数据库的设计都要求达到第三范式。
范式 | 描述 | 反例 |
---|---|---|
第一范式 | 每个字段都是原子的,不能再分解 | 某个字段是JSON串 |
第二范式 | 表必须有主键,主键可以是单个属性或几个属性的组合;非主键属性必须完全依赖,而不能部分依赖主键 | 在好友关系表中,主键是关注人ID + 被关注人ID,但表中还存储了关注人名字、头像等字段 |
第三范式 | 没有传递依赖:非主键属性必须直接依赖主键,而不能间接依赖主键 | 在员工表中,有个字段是部门ID,还有其他部门字段,比如部门名称 |
但在互联网应用中,为了性能或便于开发,违背范式的设计比比皆是,如字段冗余、存储JSON串、宽表等。如果系统是重业务性的系统,对性能、并发的要求没有那么高,最好保证数据库的设计达到第三范式的要求。
第一个原因是做业务拆分,把一个大的复杂系统拆成多个业务子系统,之间通过RPC或消息中间件通信。这样做既便于团队分工,也便于扩展。
第二个考虑是应对高并发。但也要针对读多写少,还是读少写多的场景分别讨论。如果是读多写少,可以通过加从库、加缓存解决。如果是读少写多,或者说写入的QPS已经达到了数据库的瓶颈,这时就要考虑分库分表了。
另外一个考虑角度是数据隔离。如果把核心业务数据和非核心业务数据局放在一个库里,一旦因为非核心业务导致宕机,核心业务也会受到牵连。分开之后,区别对待,投入的开发和运维人力也不同。
在分库之前,数据库的自增主键可以唯一标识一条记录,在分库分表之后,需要一个全局的ID生成服务。开源的方案有Twitter的Snowflake,各大公司往往也都有自己的分布式ID生成服务。生成的ID是完全无序,还是趋势递增,或者呈更严格的单调递增,方案也不尽相同。
有了全局的ID,接下来的问题是按哪个维度拆分。比如电商的订单表,至少有三个查询维度:订单ID、用户ID、商户ID。假设按用户ID维度拆分,同一个用户ID的所有订单会落到同一个库的同一张表里。按用户ID查,可以很容易地定位到某个库的某张表,但如果按订单ID或商户ID维度查询,就很难做。
对于分库分表之后其他维度的查询,一般有一下几个方法:
建立辅助维度和主维度之间的映射关系(商户ID和用户ID之间的映射关系)。查询的时候根据商户ID查询映射表,得到用户ID;再根据用户ID查询订单ID。但这里有个问题:映射表本身也需要分库分表,并且分库分表的维度和订单表的分库分表维度还不同。即使映射表不分库分表,写入一条订单的时候也可能需要同时写两个库,属于分布式事务问题。对于这种问题,通常也只能做一个后台任务定时比对,保证订单表和映射表的数据最终一致。
同一份数据,两套分库分表。一套按用户ID切分,一套按商户ID切分。同样,存在写入多个库的分布式事务问题。
还是两套表,只是业务单写。然后通过监听Binlog,同步到另外一套表上。
把订单ID和用户ID统一成一个维度,比如把用户ID作为订单中的某几位,这样订单ID中就包含了用户ID信息,然后按照用户ID分库,当按订单ID查询的时候,截取出用户ID,再按用户ID查询。或者订单ID和用户ID中有某几位是相同的,用这几位作为分库维度。
分库分表之后,Join查询就不能用了。针对这种情况,一般有下面几种解决方法:
这种做法非常常见,因为数据库全是单表查询,也大大降低了产生慢查询的概率。
很多时候会有这样的情况:需要把Join的结果分页,这需要利用MySQL本身的分页功能。对于这种不得不Join的情况,可以另外做一个Join表,提前把结果Join好。
对于第二种方法中的场景,还可以利用类似ES的搜索引擎,把数据库中的数据导入搜索引擎中进行查询,从而解决Join问题。
做了分库分表之后,纯数据库的事务就做不了了。一般的解决办法是优化业务,避免跨库的事务,保证所有事务都落到单库中。
如果实在无法避免,需要分布式事务的解决方案。分布式事务是个系统性的问题,后面会专门论述。
关系型数据库在查询方面有一些重要特性,是KV型数据库所不具备的,比如:
这些特性的支持,要归功于B+树这种数据结构。
数据库对应的B+树逻辑结构有几个关键特征:
不过,基于B+树的特性,会发现对于offset这种特性,其实是用不到索引的。比如每页显示10条数据,要展示第101页,实际上数据库要把前面的1000条数据都遍历才能知道offset的位置。对于这种情况,一种可行的办法是不要用offset,而是把offset的位置换算成大于某个ID的语句来实现。