正所谓“完事开头难”,在设计技术方案时候,除了前期要做好背景调查、需求调研,开工动手的第一步就是做“数据建模”,也就是存储数据的结构设计,大部分时间是围绕关系型数据库进行的,少部分是在Redis
上做K-V
延伸,而ES
、MongoDB
、Hive
等几乎都是关系型数据库核心存储的副本,结构上基本保持同步。
数据建模大有讲究,除了基础的必知必会能够让我们少走弯路,不让技术方案显得不伦不类,还需要根据特定场景去进行一些巧妙设计。下面我们从基本部分聊聊常用的设计规范和原理,再从进阶部分展开下某些场景中数据建模的设计实践方法,下面我们主要围绕我们常用的核心存储 ———— 关系型数据库MySQL来论述下数据建模的种种CASE。
『基础』夯实底座,设计不扭曲
必带伙伴,缺一不可
当我们开始进行数据建模的时候,都会先将要表达/存储的数据进行分类、抽象,将其映射成一个个实体对象,然后剖解实体对象的属性,经历从现实到抽象,从抽象到存储的两个演变过程完成。
对于关系型数据库来说,实体对象的属性就是数据库的字段,一般是一一对应的关系。对于字段而言,我们可以将其划分为必选字段、业务字段、辅助字段三类。
字段分类 |
字段属性 |
要求 |
作用 |
必选字段 |
主键(id) |
递增、唯一 |
维护数据存储有序性 |
创建时间(create_time) |
时间戳 |
记录数据创建时间 |
|
更新时间(update_time) |
时间戳 |
记录数据最后更新时间 |
|
删除时间(delete_time) |
时间戳 |
记录数据删除时间 |
|
数据状态(status) |
有效、无效 |
记录数据状态,逻辑删除标记 |
|
业务字段 |
对象属性映射 |
- |
记录业务数据 |
辅助字段 |
扩展字段(ext) |
一般为字符串,存储JSON格式 |
记录字段之外的扩展信息,可以根据业务事实表的复杂度扩展 |
业务状态机(biz_status) |
状态机枚举 |
根据业务事实表进行枚举,驱动数据流转 |
|
业务节点时间(xxx_time) |
时间戳 |
有一些业务对时间过程记录非常苛刻,需要根据业务节点进行记录,也可以把一些核心时间记录字段为审计字段 |
理论上数据库的表字段允许存储的数量足够满足业务诉求,但是我们并不推荐无限在一张表上进行无脑扩张,因为行数据的存储也依赖表字段设计的数量大小,过多的单表字段堆积会降低检索效率,如业务场景丰富且复杂,建模表字段过于多,应该适当进行垂直拆分到多个表中,通过业务字段进行关联。
逻辑删除和物理删除
对于数据的可用性来说只有有效和无效之分,对于无效数据来说,当用户点击删除键而且对提示窗口明确点击确认那一刻开始,它就已经成为历史不会再被使用了,对于不再使用的数据再继续进行存储的话的确是没有任何意义的,一般我们可以选择将其永久从磁盘删除掉。
而特别有意思的是,对于MySQL
的B+Tree
而言,顺序添加数据的成本最低对于树的维护是最友好的,而乱序插入、修改、删除由于变更需要维护树的有序性都会一定程度上造成性能损耗,是比较笨重的操作,特别是在这个树繁衍得非常庞大达到一定数量级时这种删除操作更是不推荐使用。
一般而言,我们会通过一个特殊字段来标记该记录的数据有效性,删除操作并不是直接Delete
掉数据库中的的持久化数据,而是通过数据层的标记让其”失效“而不影响物理层存储的结构变化,这就是所谓的”逻辑删除“。
其实对于MySQL而言,进行Delete
操作并不会立即对B+Tree数据进行物理清除操作,也是先标记失效,而且硬删除也会影响整颗排序树的结构变化,在业务高频场景下会造成一些性能或“雪崩”风险。
对于硬盘存储来说,整体的数据库表空间文件体积不会因为删除了数据而减少表空间体积,除非进行重建操作进行碎片整理来释放历史空间碎片,而一般不会频繁做这种重建操作,因此业务操作的“删除”不要直接映射理解成“物理删除”,而是在技术层采用“逻辑删除”进行处理,逻辑屏蔽数据即可,因为两种处理对于数据库来说没有太大差别和收益,而对于应用业务来说,“逻辑删除”可以帮助业务进行数据留痕、溯源,会有一定收益价值。
主键规则只有一条
对于MySQL
、Oracle
等关系型数据库而言,存储数据最基础的概念之一是要理解什么是数据聚簇,相关概念面试时会问到什么是聚簇索引?什么是非聚簇索引?它们的区别是什么?
真实数据挂载的索引树我们一般称之为聚簇索引,因为数据存储都是挂载在这颗B+Tree
上进行组织构建的,而维护这颗树节点顺序的就是主键。
因此,主键规则只有一条,那就是维护聚簇索引这颗B+Tree
上挂载数据的有序性,除此之外不太应该也不推荐把更多职责能力强加到其身上。开发应用过程中我们都会给表定义一个叫做Id
的主键,其意义就在于此。在一些关系型数据库中表的主键也并非是必须要明确定义,数据库可以隐式地使用rowId
等来充当表主键角色。
对于主键ID的使用,有两点需要主要的地方:
- 推荐使用递增ID。使得插入数据可以紧紧依赖树的特性递增挂载从而不需要改变过分维护树的有序性和打破整体平衡性。如果你使用一个无序的随机数进行插入,无疑会给数据插入过程带来”灾难“,挂载的寻找路径加长,徒增维护树平衡的成本。
- 不要充当业务属性。由于主键ID是唯一的,因此主键在一定程度上可以代表该表中数据的唯一凭据,因此很多工程师在设计表的过程中会错误的把主键ID当做业务属性字段来共用,比如这个业务ID、订单号、流水号等,这是极其错误的设计!主键ID的唯一规则就是在存储层做数据存储的”向导“来保持存储插入的有序性,此时不应该再强加其他业务属性进行耦合,这显然没有做好存储层和业务层的隔离与区分,更有甚者会把主键ID直接当做订单号、流水号进行存储,还有业务属性字段一般需要和其他表或者外部服务业务进行关联的,在一定程度上还是有被修改的潜在可能,很显然主键ID并不适合。
为什么要反范式设计
范式:一张数据表的表结构所符合的某种设计标准的级别,可参考数据库规范化、数据库范式。
在说明反范式设计之前,先来回顾下什么是范式设计原则。在关系型数据库的建表过程中,为了让库表设计更加合理,需要遵从一定的设计原则,从而评估设计的规范性、合理性。范式就是符合某一种级别关系模式的集合,目前关系数据库从不严格到严格依次有五种范式:第一范式(1NF)、第二范式(2NF)、第三范式(3NF)、第四范式(4NF)、第五范式(5NF),下面我们通过一张电商通用订单表模型来论述下:
范式 |
要求 |
举例 |
1NF |
某个业务字段无需再拆分,已经是最原子 |
订单表中的订单号、购买人、商品SKU等 |
2NF |
在1NF的基础上,需要确保数据库表中的每一列都和主键相关联,而不能只与主键的部分相关联 |
订单表中会涉及购买商品SKU、购入数量、下单时间、付款时间、用户ID等: |
3NF |
在2NF的基础上,字段需要和主键具有强关联性,而不应该和主键之外的数据有关联性 |
订单表中会存订单号、订单名称,也会关联存储下售卖商品SKU,根据3NF要求不需要额外存储商品名称,因为商品名称只和商品SKU强关联,和订单表主键不是强关联关系 |
4NF |
如果A、B、C是两两成对出现,则需要它们三个是不相交的数据集合 |
反例: 订单号(B0001)、水果(苹果)、购入数量(5个) |
5NF |
属于最终范式,也称为“完美范式”,消除了连接依赖,表必须可以分解为更小的表 |
现实中应用很少,只是站在数据库规范层面约束,不一定具有实用性 |
💁 反范式设计的一般场景
- 违反1NF
- 『设计』
ext
字段存储JSON
格式数据为未来数据留出拓展 - 『好处』 使单字段内聚结构化数据,存储表达丰富,支持一定程度的扩展
- 『坏处』 脱离关系数据库字段级定义,无法进行关联,存储数据需要再次格式解析
- 违反2NF
- 『设计』 不对表字段属性进行精细拆分,允许和主键不强关联的属性共存,如订单表可以共存用户ID、商品信息、支付信息等
- 『好处』 不需要联表查询,查询性能快,适合简易业务场景
- 『不足』 数据区分度、隔离不好,不便于后续拓展
- 违反3NF
- 『设计』 冗余存储和主键不强相关数据,如订单表存储商品SKU,额外存储商品名称。
- 『好处』 不需要联表查询,直接在本表获取数据
- 『不足』 冗余存储增加空间成本,需要考虑维护数据一致性,适合不常变更数据,比如订单号、字典枚举等惰性数据
❓ 反范式设计到底好不好
既然针对关系型数据库做出了诸多设计原则约束,为什么还要做反范式设计呢?
设计原则是一条基准线,起到引导作用,是从关系型数据库的数据关联关系的适用性出发梳理而来,目的是让我们基于关系型模型的基础上做出更好的符合关联关系条件的库表设计,而它并不是一个最终设计实践的评分标准。
我们常说实践出真知,一切要从实际出发,由于数据的核心作用还是存储和查询,现实设计过程中我们除了考虑关系型逻辑匹配的合理性,还要充分考虑使用性能和具体情况下的扩展空间取舍,违反常识的烂设计理所当然需要严格规避,而在合理地基础上进行反范式设计并不是“坏味道”,它也会给程序带来出奇的收益。
垂直和水平拆分
当你觉得处理一件事情非常复杂时,最核心的是要梳理清楚细节,进行分类,然后逐个击破,从局部到整体“放大抓小”最终解决这件事情。
在架构设计原则上会经常提及水平拆分、垂直拆分的概念。
- 水平拆分 目的在于扩展单点容量的资源限制、能力负载,对数据库的水平拆分通常而言就是做分库分表操作,单库单表的原始设计是1库1表,可通过定义切分键扩展,切分键的选择比较讲究,可以是某个单独字段,也可以是多个字段的组合,作用是进行数据存储和查询的路由,如扩展为N库M表,则单表共有
N*M
表的扩展结果。 - 垂直拆分 目的在于将复杂的组合体拆分成一个个原子、独立、边界清晰的模块,对相对小规模或领域的治理总是要比全局更为容易抓到重点、更面向具体特征来建设和发挥优势。如一个支付系统,一般会根据领域功能拆分为订单域、支付域、用户域、商品域等等,每个模块领域还会拆分自己的细分领域,这也正是微服务的一部分内容,垂直拆分后的模块更内聚,设计开发协作职责更聚焦和清晰,对于效率有很大提升。
外键慢慢被遗弃
在数据库建模初始,表与表之间的关联关系依靠主键、外键进行维系,表之间关系维护就像一根绳子系着两头,自己的这头绑在主键上,另一头绑在外键上,主外键是相对而言的。
这种关系约束方式是在数据层处理的,对于数据准入、修改有着非常严格的要求和检查,对于数据完整性来说是可靠的保障手段,但也可想而知带来很大的成本问题。在现如今的开发设计中,我们几乎已经抛弃数据层外键约束这种设计,而是上移到应用层进行逻辑处理保证数据完整性。
单拎出这个Case是想强调,我们一直在说层次分明、职责单一的设计原则,尽管数据层组件也能提供一些如存储过程
、函数
、触发器
等逻辑处理能力,但最本质的能力还是要回归存储、查询,这像极了我们做每一件事情的过程,从微小做到庞大,从简单做到复杂,最终还是要回归本源,让事情整体有层次、细节更聚焦本身。
为什么要有UUID
UUID(Universally Unique Identifier)
即唯一通用识别码
,就像每个人的身份证号一样,每个人都是唯一的,数据存储也是如此。
一般而言,单库单表的数据可以依靠设计自增主键来担任UUID
的角色,因为主键ID是唯一的不会重复,而进行水平扩展后表增多,主键ID只能代表单表唯一性,需要设计一个UUID
字段来充当全局的主键ID标识全局唯一性,此时的作用是水平扩展后产物。
当下在数据层存储的逻辑关系是,依靠MySQL
来做核心存储,通过binlog
方式同步到其他数据层组件中进行异构存储,构建时可以根据数据特点和检索目标来进行数据切分,按时间维度的话可以选择使用行数据必选字段创建时间转化时间戳进行切分,一般业务的数据增长趋势和时间轴是正相关且均匀趋势的,这种time series
的存储方式既有利于数据切分,也对时间范围跨度查询非常友好。而数据同步建构的过程本身就是一个分布式事务的话题,异常不可避免,发起重试需要保证数据逻辑幂等,此时UUID
就是幂等主键。
『进阶』开阔视野,方案更优雅
如何设计防重
防重设计可以从发起、过程、存储等阶段介入设计,贯穿服务请求全量路。
- 『展示层 - 置灰提交』前端数据请求提交时进行置灰设置,避免提交过程中的重复点击提交,这种处理会把压力分散到每一个用户设备端个体上,将压力消解前置,在入口处进行治理。
- 『逻辑层 - Token机制』Token机制又名“令牌”机制,需要前后端进行协作完成,先获取令牌授权,通过令牌再去请求具体服务请求,一个令牌只能使用一次,用完即毁并失效,起到限流和拦截作用。
- 『数据层 - 数据库唯一键』数据库的唯一键设计保证了数据插入的唯一特性,这是防重设计最后一道防线,也是最可靠的一层防线。关于唯一键的设计可以定义在事实表上一个字段来进行,也可以联动辅助表的某一个唯一键来进行事务提交。比如支付订单的防重提交可以定义上游业务单号的存储字段唯一,代表不可重复下单和支付多次。
位运算的巧妙利用
位运算的特点可以用短小精悍形容,天然适配机器计算逻辑效率很高,而且存储空间通过变更占位实现成本很低,不足是每一位只能代表0
、1
,因此每一位的含义可代表性基本局限于True
和False
语义。
缘于位运算的特征,可以用一个数值类型字段来存储,将数值二进制化,根据数值类型长度进行占位,比如一个16位的字段可以存16个业务含义,表示业务True
则为1
,否则为0
,非常适合标签、标记等场景的使用,此时无需在数据库中定义多个字段代表,比如一个用户信息系统的标签有是否学生、是否VIP、是否实名、是否注销等,可以定义一个标记字段进行二进制位存储即可。
除了存储简易,还能支持高效率的与、或、非取值计算,举例如下:
- 二进制占位分布
1<<0 0001(二进制) 1(十进制) 标记业务含义为「有效用户」
1<<1 0010(二进制) 2(十进制) 标记业务含义为「VIP用户」
1<<2 0100(二进制) 4(十进制) 标记业务含义为「学生群体」
1<<3 1000(二进制) 8(十进制) 标记业务含义为「风险用户」
1011 即 「有效用户」「VIP用户」「风险用户」
- 判断判断标识
# 目标数据
1011(二进制) 11(十进制)
# 判断
1<<0 | 11 == 11 true 是否为「有效用户」
1<<1 | 11 == 11 true 是否为「VIP用户」
1<<2 | 11 == 11 false 是否为「学生群体」
1<<3 | 11 == 11 true 是否为「风险用户」
存JSON到底好不好
基于范式设计原则,字段设计要确保尽量原子、不可再分,随着JSON文本结构化普及后,很多技术设计似乎找到了“另辟蹊径”的小技巧,逐渐也开始在一个extra含义的字符串类型的字段上进行扩展性的设计,为后续变化“留口子”。
先来说下这种设计较好的收益。我们不需要频繁的面对结构调整而带动库表结构变化和应用代码的联动,变动成本低,利用非关系型数据结构化的解释特性完成逻辑自治效果,而且越来的数据库如Mysql
、Clickhouse
等开始支持类似JSON_EXTRACT
的JSON结构解析函数,可以将拍平的文本数据结构化的提取出来,因此一些结构不确定的、结构变化灵活的数据存储可以借鉴此种设计,存储结构上拍平,取数使用时借助数据库函数能力甚至在应用层解析处理完成复杂的用数逻辑。
再来说下这种设计潜在的问题。由于结构数据被拍平,也就意味着失去了关系型数据的联动特性,无法深入到结构数据内部去提取层次来进行外部关联,缺少解释过程是无法逾越的鸿沟。另外,由于结构化数据有潜在膨胀的风险,比如可以在业务遇见范围内使用varchar
来存储65535
字节以内的数据,如果varchar
满足不了也可以直接配置为longtext
类型来存储极大的业务数据满足容量需求,但是存储成本上需要做适当关注,由于同字段的数据容量的差异可能会有大量数据库碎片产生,感兴趣可以自行拓展验证。
总结而言,JSON结构存储到关系型数据库字段有它存在的合理性,对于技术设计有现实扩展帮助,数据库自身也越来越多开始迎合这种设计理念和用户需求提供一定支持,需要结合场景充分评估数据量级,围绕存储成本、取用数逻辑来进行实践即可。
加密存储怎么搞
在业务数据存储的过程中,合规审计部门要求存在敏感等级风险的数据要保持加密存储,保证数据的安全性。
加密存储可以在数据层通过数据库的存储过程、触发器等进行维护和更新,也可以在应用层结合加密服务能力进行数据干预。
- 『数据层』
- 『应用层』