数据结构(数据库)设计规范

原创
2019/05/27 00:19
阅读数 1.7K

表命名规范

表命名的规则分为3个层级,层级之间通过_分割,例如b_r_identityd_l_identity。规约为:

[leavel]_[type]_[name]

[leavel] 表示数据库表的层级和功能,分为:

  1. s:业务无关的系统数据表。
  2. d:业务字典表。
  3. b:基础业务表。
  4. v:视图。
  5. i:聚合中间表。

[type] 表示数据库表的类型,分为:

  1. r:行数据表。
  2. l:列数据表。
  3. g:分组数据表。

[name] 用来表示表的作用名称,由于mysql默认对大小写不敏感,采用下划线命名法。比如: identity_enterprise

因此,综合上面的规范,如果将用户模块命名为user,相关的表命名为:

  • 数据字典以列数据的方式存储,可以命名为:d_l_user_dc
  • 用户主表以行数据的方式存储,命名为:b_r_user
  • 用户账户表:b_r_user_account

采用以上命名法的目的:

  1. 便于代码开发阶段区分表的功能和数据组织形式;
  2. 通过前缀为以后可能会引入的开源框架生成的表扩展命名空间。

比如引入了流程框架activity,会向数据库添加几十个表,其中有名为account的表,如果不适用前缀,会增加引入的成本。

字段命名规范

  1. 逻辑主键:id。所有的表必须创建逻辑主键。采用统一的主键便于分库分表以及数据抽取。
  2. 业务主键:code, 确保唯一性或联合主键。业务主键除了反映真实数据关联,也便于程序进行类型判断。
  3. 外键:columnName_fk,字段信息名+fk后缀,比如state_fk。
  4. 父主键关联:pid

行数据规范

所有的表必须包含modify_date、modify_type、modify_user、modify_access_id、activity字段。

  1. modify_date:标记数据修改时间,用于数据增量ETL或缺陷回溯。类型:TIMESTAMP(13)
  2. modify_type:数据修改类型,通常数据由运营后台修改OPR(0),或账号拥有这修改USR(1)。用于记录数据修改的行事人。类型:TINYINT(1)
  3. modify_user:结合modify_type,标记是修改人。类型:BIGINT
  4. modify_access_id:在数据库中用于标记当前数据修改是由哪个访问id导致的。类型:BIGINT
  5. 所有的时间字段均以时间戳(Java十三位标准)的方式存储,Mysql对应TIMESTAMP(13)类型。

ER范式规范与反范式规范

ER三范式

在联机事物数据库中(Mysql的InnoDB类型数据库)业务数据与业务数据(命名以b开头的表)之间的ER关系严格按照ER三范式进行设计:

三范式原则:
1. 有逻辑主键、主键非空、主键唯一、字段不可再分。
2. 具备独立不依赖数据内容的逻辑主键。
3. 没有属性传递(不能有数据冗余)

反范式适用范围

业务字典允许数据传递

业务数据(b开头)与数据字典(d开头)之间的依赖关系允许数据冗余(传递)。

满足以上条件必须保证业务字典数据演进严格依赖开闭原则:类似设计模式的开闭原则——数据修改关闭,数据添加开放。

这个时候可以在业务数据表中对业务字典进行数据冗余,例如:

字典:

id pid code name ...
主键 父主键 业务编码 名称

数据表:

id state_fk state_code state_name ...
主键 父外键 状态编码 状态名称 ...

数据仓库或视图完全反范式

数据仓库(MyISam)或者事物数据库(InnoDB)中的视图和数据中间表优先使用反范式的规则去实现。

参考阅读

关于范式设计的说明请参阅后文的**“ER范式与反范式设计规范设计背景及原因”**。

主键规范

逻辑(物理)主键使用64bit的BigInt类型,通过Snowflake算法获取。它可以完全充当Mysql主键,也能平滑兼容MyCatSharding-jdbc(3.0后更名为Sharding-Sphere)等开源分库分表数据源管理工具。

业务组件原则上不做任何关联查询,只用于标记单表业务内容。

采用该规范的原因请见后文主键规范设计背景及原因

数据缓存规范

缓存通过Redis实现。

key使用规范

对应数据结构必须是扁平的(更像一个反范式的数据仓库表或视图)。由于在设计数据主键时保证主键id的全系统唯一性,理论上Nosql中key使用主键即刻确保唯一,但是为了区分业务,需要使用前缀。

索引数据使用模块+业务名+key:{module}:{buss}:{key},例如

SET user:mgr:1234567891234567891

原则上key的长度不能超过64个字符。太长的业务key请使用hash进行一次签名。

主数据与业务主键索引

主数据:是指真实存储数据的结构。通常对象都是Redis中的HASH格式存储(HASH格式相当于Java中的map、Python中的dict、Json中的对象块——{}),功能数据数据根据需要使用string、set或list。

业务主键索引:在我们使用key-value进行数据存储时,用于查询的key并无法满足业务,有时候数据是用业务主键传参的,这个时候需要创建一个指向真实主数据的索引,建立业务主键和逻辑主键的关联关系。这个时候在Redis中可以使用业务索引映射主数据key然后再取值的方式,比如下列格式:

SET {module}:{buss}:{code} {module}:{buss}:{id}
set hkey {module}:{buss}:{id} id 123 name "Alice" age 20

这样,业务主键code通过2次索引可以找到id对应的主数据。

Value数据规范

Java中的实体对象使用Redis中的HASH格式存储,确保数据的扁平化,不能使用嵌套结构。例如

//正确
id:{
    name:"Alice",
    age:28,
    pos:"developer"
    salary:100000
}
//错误
id:{
    baseInfo:{
        name:"Alice"
        age:28
    },
    workInfo:{
        pos:"developer",
        salart:100000
    }
}

其他应用场景可以根据需要使用Set、List、String,请注意他们的差异(与Java对应的数据结构类似),并且一定保证数据扁平。

数据读写规范

  • 当需要批量读写数据时,使用mset、mget等原生批量处理命令,Redis自动保证数据原子性。
  • 尽量不要使用通道——pipeline。
  • 不要使用Redis做事务处理。
  • 禁止线上使用keys、flushall、flushdb等,管理员redis的rename机制禁掉命令。(严重影响性能)

参考资料:Redis更多的规范参考阿里云Redis服务使用规范


主键规范设计背景及原因。

在分布式微服务系统中采用Mysql的自增主键在分表分库、灾备合库、分布式执行、缓存Write-Behind写时会有很大制约,因此需要制定不依赖数据库的行主键规范。

主键类型

在解释数据设计规范之前先理解物理主键逻辑主键业务主键的区别:

物理主键即认为是数据库的自身的物理标识主键,例如oracle的ROW_ID,mysql的自增Sequence,物理主键除了具备独立的物理特性,也是数据库连接数据的核心。mysql中要求单表唯一。

逻辑主键是与数据库无关的非业务意义的主键,用于对行数据的唯一性进行标识。在单数据库系统中,通常不需要逻辑主键,而在分布式系统中,逻辑主键的意义重大。无论是什么数据库,逻辑主键要求全库(所有的数据库)唯一。某些时候可以将物理主键和逻辑主键合二为一。

业务主键是指与含有业务特性的的主键,例如订单编号会以 时间+流水号+业务编号实行存在。业务主键通常的要求是单向业务唯一,由于从技术角度来说业务是随时可变的,因此业务主键并不能提到逻辑主键或物理主键。

MySql(InnoDB)索引特性

由于InnoDB的行数据排列是以主键数据(Oracle是ROW_ID)作为b+树索引,而扩展的索引都以主键索引作为数据对象——这种方式称为聚集索引。所以最大效率的保证b+树主键和索引数据进入的递增性对于数据库的性能有决定性作用(b+树越扁平,效率越高)。

使用mysql的自增Sequence可以很自然的解决这个问题,主键就向一个队列一样,只要insert数据向队列尾push数据即可,几乎不会发生索引重建和数据碎片。但是自增队在分布式系统中使用有巨大的局限性。

如果直接使用UUID既充当物理主键又充当业务主键,由于 UUID并无法保障数据的递增性(?),会导数据碎片已经主键索引更新效率。此外UUID的长度是32位字符串,即使用ascii的编码方案,也会占据不少的空间。

传统中间解决方案

基于Mysql目前也可以自动生成UUID,所以有一种中间解决方案是在分布式系统的数据库中物理主键使用Mysql的自增Sequence,逻辑主键使用UUID,所有的ER关联都使用UUID建立,这样可以很好的保障聚集索引添加数据的效率,且能极大减少碎片。由于InnoDB聚集索引除了主键索引都会引起二次查询,所以这种方式外关联效率较差(即使是单表查询效率也一般)。

主键需求

整合以上内容,现在我们需要一个具备以下特征的主键:

  1. 递增。
  2. 全系统唯一(至少保证单业务唯一)。
  3. 高效产生。
  4. 尽量短。(减少扩展索引的存储空间)

连续递增与趋势递增

对于B+树递增要求的并不需要连续递增(0,1,2,3,4......),只要趋势递增即可(0,3,5,7,18,100.....)。

Snowflake算法

为了满足主键需求,现在比较推崇的是Snowflake算法

Snowflake算法

Snowflake算法会产生一个64bit的数据,正好在Java中是一个long类型,对应Mysql是一个BigInt类型。

  1. 第一位是符号位(正负号)。在使用过程中基本不用理睬。
  2. 其后的41位表示时间戳的差值。
  3. 10位工作机id称为workid,需要人工指定。10bit=2^10=1024个Id
  4. 后续的12位用于在微秒级别生成序列号。

效率:

  • 因为其本质上还是一个数字,所以在关联查询能力上不会比源生的自增Sequence的差多少(微秒/纳秒级别)。

  • 官方文档Snowflake Id算法理论上单机每秒可以生成409.6万个ID——1000个微秒单位,12位序列编码=1000*(2^12)。

递增性质:

算法是以微秒+递增序列作为区分的,并且时间单位处于64bit中的高位,在所有的微服务节点没有达到生成极限时(每秒409.6万个)一定是趋势递增的,计时达到了极限,也仅仅在时间单位出现相同。

传输:

64bit的long类型转换为十进制只有20个数字,由于64bit的第一个位置表示符号,所以实际只有19个数字。在http报文中仅仅是19个字符。如果将其转换为16进制或[0~9a~z]满表的36进制。长度还能极大的压缩。

局限性:

  1. 由于其本质是基于微秒级的机器时间戳进行ID生成,所以当整个集群有时间一致性服务时候,可能会发生时间回拨(也有可能是人为修改,不过几乎不可能发生)。当时间发生回拨时就会有极大的概率在回拨时间区内出现主键冲突。百度有个Snowflake算法变种解决方案是使用中心化的按块生成ID尽可能的回避这个问题。此外如果并发并没有达到极高的程度时,可以让入口服务器来统一生成access_id作为后续业务新增数据时的主键,当然这也没法完全解决这个问题。

  2. 64bit的算法如果要求全系统主键唯一,那么基于算法的workid特性最大支持1024台服务器同时生成主键,再多就会出现冲突。解决办法就是不要求全系统唯一,而收敛为单个业务唯一,这样可以视为单个业务可以具有1024个分布式服务。

  3. 其数据位数决定了其从使用开始最多服务61年,61年后出现类似于千年虫的问题超出现有数据位。

参考

  1. Snowflake算法最早由推特twitter的工程师创立并开源,现在整合到RPC框架Finagle中,当然没必要引入整个Finagle,可以到这里下载核心算法
  2. Snowflake有个非常方便的使用途径是引入sharding-core。然后使用其中的SnowflakeShardingKeyGenerator。当然Snowflake算法本身并不复杂,使用源码就能解决问题,而且具有极佳的扩展性。
  3. 算法中workid最大支持1024,通常可以通过主动命名、ip地址、服务器命名等方式决定。
  4. 网上关于Snowflake算法的极少很多,自行查询解决问题。

ER范式与反范式设计规范设计背景及原因

业务字典与代码适配器

通常情况下,在一个标准的联机事物系统中数据库和业务代码的关系可以总结为:

  • 数据库的的作用就是存储业务数据和状态结论。
  • 业务代码是为了处理数据从外部进入或数据库内部的状态迁移。

例如一个用户注册,通过代码各种处理和鉴权之后,最终会将用户的各种注册信息写入到数据库中。一个用户登录会涉及到数据库已经存在的数据的变更。

数据库可以看成一个静态的网状结构,只是忠实的在网状结构的某些点记录“结论”,结论通常由2部分组成:数据+状态。与之相对的,代码的作用就是接收外部信息后触发数据库中的状态迁移和数据演进。业务代码最困难的也是导致问题最多的就是状态控制与状态处理。

对于代码而言,处理各种状态是最常见的过程,例如:

if(state = A){
    do A processor
}else if(state = B){
    do B processor
}

一个不规范的数据库状态的存储方式千差万别,例如一个用户信息表user_info有以下结构:

id name user_state ...
1 Alice 0 ...
2 Bob 1 ...

现在有一个user_state字段表示用户状态,然后当值为0时表示注销、当值为1时候表示启用,或者还会有其他更多的状态不断添加进来。

这样会导致3个问题:

  1. 0和1并没有实际意义,在设计初期可能会很有意识的在字段备注中说明每个数字带边状态意义,但是在系统不断的进化或迭代后,0和1的控制难度会越来越大。
  2. 状态的不同必然会导致分支处理,分支处理最粗暴的方式就是上面的代码例子会进行if-else堆叠,随着状态的增加,整个系统的if-else块会飞速递增。
  3. 无论是否使用0和1还是有实际意义的单词标记状态。在理解的过程中要嵌入大量的if块去了解处理的结果,可读性很差。从而导致新加入项目的开发人员无暇去根据已有的状态阅读代码,而更趋向于增加状态而非在原来的状态上进行演进。这样会导致一个简单的业务标识状态的字段会越来越多。

基于以上原因,引入业务字典来解决这个问题。将上面的状态控制拆分为业务字典表和业务表。

业务字典表

code name ...
activity 账户启用 ...
locked 账户锁定 ...

数据报表

id name user_state ...
1 Alice activity ...
2 Bob locked ...
//伪编码

interface Processor{
    Result Do(Data data);
}

class Activity implements Processor{
    public Result Do(Data data){
        //DO
    }
}

class Locked implements Processor{
    public Result Do(Data data){
        //DO
    }
}

class AdapterFactory{

    //写Java的请注意
    //这里用js或Python的方式表示一个map结构(python中叫dict结构,js中叫对象结构)
    //只因用Java表示一个map太麻烦
    Map processorDict = {
        "activity":Activity.instance,
        "locked":Locked.instance
    }
	
    public static getProcessor(enum state){
        return processorDict[State];
    }
}

//使用
Processor processor = AdapterFactory.getProcessor(state);
processor.do(data);

使用以上方式。首先在业务流程中不必嵌套大量的if。其次可以将代码与业务字典的状态进行1-1绑定,即一个状态对应一个适配器。最后需要扩展更多的状态的就是对应扩展更多的处理器(适配器)。

咋一看这样的写法似乎比if多不少代码,但是用这种状态+适配器的模式在系统迭代时能够带来极大的便利。

数据仓库的反范式

严格按照ER三范式设计数据库带来的好处是能最好的反应真实业务,能及有效的约束数据强一致性避免“脏数据”的出现。带来的问题是大量的外关联导致大量的关联查询,严重影响查询性能。

因此建议如果某项业务启用了数据仓库(MyISam),或者在事物数据库(InnoDB)中使用了View或中间表集合数据,务必严格按照Kimball或Inmon模型使用反范式创立单体数据仓库表。

展开阅读全文
加载中

作者的其它热门文章

打赏
1
3 收藏
分享
打赏
0 评论
3 收藏
1
分享
返回顶部
顶部