做好设计:存储设计基础

发布时间 2024-01-07 05:19:53作者: 琴水玉

存储设计之于软件开发,犹如打地基之于造房子。


引言

“软件设计要素初探” 一文,尝试从整体视角讨论了软件设计涉及的各种要素。本文探讨软件详细设计中的关键环节:存储设计。

存储设计是领域/业务建模的设计细化,确定了数据的主要属性特征、组织结构与关联、领域对象的表达。存储设计基本决定了应用的数据质量和业务支撑能力。存储设计是在做任何新系统、新增业务需求、技术重构的研发活动中不可或缺的关键环节。存储设计通常要考虑读写操作的性能、容量、并发、事务、搜索。

基础知识

存储设计基础主要关乎表和字段的设计、关系范式的合理利用。

存储要素

存储要素涉及:

  • 有哪些数据,数据从哪里来;

  • 存什么数据;

  • 如何组织数据;

  • 存哪里,如何存;

  • 数据如何展示。

存储设计展现了系统的信息视图。

组织良好的信息视图,应当遵循优先级法则,把关键信息凸显出来。

按照重要性对字段进行分级。关键字段放在最前面,主要字段放在次前面,常用字段放在随后,次要字段放在最后。在应用程序里应当遵循此法则。

  • 表名:清晰直观表达对应的业务用途;具有区分度,能从大量表名中一眼看到;能够与历史表区分出来(技术重构)。
  • 引擎:不同的引擎支持不同的功能。对于大多数事务型应用,通常是选择支持事务的。
  • 字符编码集:一般选择 utf-8。
  • 分表设计: 水平分表;垂直分表。比如主表和扩展表;业务表与统计表;主要是提升查询性能和业务可维护性。
  • 分库设计: 水平分库;垂直分库。比如根据 买家ID 分成 订单库分成 32 或 1024 个库。主要是抗高并发(单库连接数有限)。
  • 分片设计: 通常是针对 NoSQL 而言。采用时间分片或哈希分片。降低单个分片单位的存储和读写压力,具备可伸缩扩容能力。

字段

  • 字段名: 清晰直观表达对应的业务含义;易于为开发人员和领域专家所理解和交流;采用常用词汇。
  • 字段类型: 精确、合适;可读性与存储容量的权衡;同一字段的类型保持一致。
  • 标准字段: 多个业务共享的通用字段需要标准化,遵循单一事实准则。
  • 主键与唯一键: 主键与唯一键的选择。主键必须有,唯一键可选。
  • 大对象存储: 引用地址与压缩。
  • 外键设计: 合理处理一对多、多对多的情形。
  • 冗余设计: 性能与可维护性的权衡。
  • 空与默认值:数据的兜底保证,确保无脏数据。
  • 注释: 清晰直观。

关系范式

  • 关系范式: 范式的基本约束;违反范式的后果。
  • 范式的特例: 可维护性与效率的权衡。

关系范式的根本目标即是将独立的属性及属性依赖关系分别抽离出来,成为单独的表。这里属性与字段同义(字段是通俗用语)。

  • 1NF: 数据项原子不可再分。违反 1NF 会导致 根据未分开的某个字段的查询性能低比较麻烦;
  • 2NF:符合1NF,并且,非主属性完全依赖于码。码是唯一标识一条记录的最小元组,即这个码的任何真子集都无法唯一标识一条记录。违反 2NF 会导致信息冗余、插入异常、删除异常、更新异常。
  • 3NF: 符合2NF,并且,消除传递依赖。违反 3NF 会导致信息冗余、重复更新、插入异常、删除异常。
  • BCNF: 符合3NF,并且,主属性不依赖于主属性。违反 BCNF 会导致信息冗余、重复更新、插入异常、删除异常。

为什么会出现插入异常、删除异常、更新异常? 根因在于表中存在 (A,B) -> C 且 A -> C,原本 A->C 的关系是独立的,但因为放在表 (A,B,C)中没有抽离出来。这样,如果只有 A,C 没有 B,则无法插入该记录(A, ? , C),因为 B 是主属性且主属性不能为空;删除了某条记录(A1,B,C1),就会导致 (A1, C1) 的关系被删除(现实中不允许);而更新的话,如果要把 A1 更新为 A2,则需要把所有 (A1, Bi, C1) 的记录都更新掉。因为独立依赖关系没有抽离出来而导致信息冗余,信息冗余进一步导致重复更新。

举例:

  • 1NF破坏: 比如,如果把省、城市都放在详细地址里,要查询或统计某个省或城市的消费者的数据就很麻烦。因此,有查询或统计需求的字段必须独立出来。
  • 2NF破坏。比如选课表 (学生学号,课程号,课程名称)。 (学生学号,课程号) -> 课程名, 且存在 (课程号) -> 课程名。那么,如果没有学生号,那么这条记录无法被插入,因为学生号是主属性;如果删除了 (学生学号,课程号,课程名称) ,且某门课只有一个学生选了,那么这门课对应的(课号,课程名称)关系就被删除了再也找不到了; 而如果课程名称发生了变化,那么所有选了这门课的选课记录都需要被更新。
  • 3NF 破坏。 比如 (教师号,教师职称,职称待遇)。 (教师号) -> 教师职称, (教师职称)-> 职称待遇。 如果只有教师职称而没有确定职称待遇,那么这条记录就会有空值的脏数据(职称待遇); 如果删除了(教师号,教师职称,职称待遇), 且这个职称只有一位老师获得,这个职称及职称待遇就被删除了,而事实上,我只想删除这位老师的职称信息;同理,如果某个职称的待遇发生了变化,就要把所有关于这个职称的记录都更新一遍。

设计考量

索引

索引直接会影响查询性能。不可不重视。

索引设计的最主要两个准则:

  • 高区分度: select count(distinct(col)) / count(*) 越高,区分度越高;id 的区分度为 1。

  • 最左匹配准则:在 SQL 中等值查询的高区分度字段放在最前面,范围查询的字段放在次之,分组排序字段放在最后面。
    推荐做法:

  • 区分度高的字段加索引。比如 业务 ID, unique_key, md5, sha256 等。

  • 有限可列的字段不需要加索引。比如类型、状态等。

  • 名称之类的通常会用于模糊索引,一般也不加索引。

  • 如果有一个区分度很高的字段,则没必要建联合索引。

  • 多个区分度较高但又不太高的字段,可以建联合索引。比如 agent_id 和 时间。

  • 时间通常要用于搜索和排序,可以与其它字段建联合索引。

  • 按照场景,把核心索引和次要索引分开。

关联设计

  • 弄清楚系统里的各个实体的关联关系(一对一,一对多,多对多)。
  • 关联设计主要有两种:外键关联和嵌套关联。
  • 关系型数据表:添加、删除、更高字段需要做表更改,灵活性不太好,通常遵循关系范式设计,采用外键关联,尽量减少信息冗余。比如 MySQL。
  • 文档型数据表:可动态添加字段,可以做嵌套关联。比如 Mongo。
  • 绘制系统的 ER 图。

维度是否合理

维度不合理,可能会性能问题和信息大量冗余。

举个例子,文件检测,通常只跟 sha256 有关,根据 sha256 来下载文件并检测文件内容。设计一个文件检测任务表,那么该表的维度应该是 sha256。如果冗余了告警ID,那么维度就是告警ID。维度变成告警ID 会有什么问题呢? 假设 有 500 个不同的文件,要生成 10w 个告警。那么如果文件任务检测表以告警ID 为维度,就要生成 10w 个检测任务,发送 10w 个检测,即使检测任务的结果是可复用的,存储和发送 10w 个检测任务是一笔很大的开销,发送10w 个检测任务也会给检测引擎会带来大并发问题。此外,10w 个检测任务中,500 个文件均有 200 次信息重复,也是相当的空间冗余和不必要的占用。

这里就能很明显看出,设计表的维度不合理导致的后果。因此,设计表时,一定要弄清楚表的维度。

分表与合表

分表与合表是另一个问题。

假设有 进程、文件、容器、IP 等不同元素的响应记录,那么是分表存储还是放在一张表呢?

假设有两个产品线,都需要存储告警信息,那么是分表存储还是放在一张表里呢?

有一个依据: 看公共字段。如果公共字段比较多,则可以合表,用一个扩展字段(json)来表示差异部分;如果相异字段比较多,则可以考虑分表。

分表需要考虑聚合分页查询问题。合表则需要考虑数据量问题和清晰性问题。如果表数据量不大,公共字段占比较多,则倾向于合表。

分表和合表有时还需要考虑产品设计。如果产品设计是不同的产品列表页,则分表更清晰;如果产品设计是合二为一的产品列表,则合并比较合适。

是否分片

分片是应对大数据量的可扩展性手段。

要估计数据日增量。如果数据日增量很小,则没有必要分片。比如那些由手动操作触发的数据表;如果数据日增量较大,则要考虑分片。比如由自动操作触发或者业务量和用户量本身就很大的情况。需要一个脚本,统计业务主表的日增量,作为后续性能优化和扩展性优化的依据。

分片的三种常用方式:

  • 根据业务ID 做 Hash 分片;
  • 根据时间做范围分片;
  • 给 VIP 客户单独做分片。

微决策

存储设计中存在大量的微决策。

存储引擎的选择

主要取决于需求: 查询支持;事务支持;全文检索支持;分析支持。

如果需要事务支持(数据一致性保证),则必须选取支持事务的引擎。

主键:32 位还是 64 位

32 位的主键占用空间小,但可扩展性有限。如果业务量大之后,32 位就捉襟见肘了。 64 位扩展性大,但占用空间也大。存储空间不仅要考虑磁盘上的,也要考虑内存里的。存储空间直接影响研发成本。

如果是一个即用即消的小系统,建议用 32 位; 如果是一个长期要发展和维护的中大系统,建议用 64 位。这与架构设计有相似之处。小系统用小而美的设计,大系统用适宜的架构。架构和设计都是演变的,而不是一蹴而就的。

这里就涉及到一个工程技术问题: 如何从 32 位“升级”到 64 位?一般做法是先修改应用程序里的主键属性类型为 Long,再改数据库里的字段类型为 bigint 。错了。如果先改应用程序里的主键属性类型,那么在存入数据库时要么报错要么被截断。正确的做法是:两个都要同时改,同时部署上线。如果是关键系统,则需要凌晨上线并进行回归测试。如果先改数据库的字段属性,再改业务系统里的属性类型,则需要回归两次,测试成本增加。

主键与唯一键

  • 主键和唯一键都可以唯一标识一条记录,不能重复。
  • 主键具备唯一键的能力,但唯一键不一定是主键。
  • 一个表有且仅有一个主键;主键通常是一个字段;主键不能为空。
  • 一个表可以有多个唯一键;唯一键可以由多个字段组成;唯一键可以为空。
  • 唯一键的主要用途是避免业务数据的重复,是一种数据约束手段。
  • 唯一键通常有业务含义,通过多个业务字段组合并和计算而得到。
  • 主键是稳定的,唯一键是灵活的。

字段长度选取

  • 整型: 布尔用 tinyint,状态用 int 或 smallint, 键用 bigint。
  • 字符串:固定长度的字符串用 char ,比如手机号、座机 ;可变长度的字符串,短的用 varchar(32) , 中的用 varchar(64), 长的用 varchar(255),更长的就是 text 了。

枚举类型:可读性与存储效率

应用中常常要存储一些枚举。那么这些枚举在数据库里,究竟使用字符串还是数字代号呢?主要考虑两点:

  • 可读性
  • 空间占用
    可读性是指这个字段定义从直观上来看是否一眼就能理解,容易排查。存储效率主要是指占用空间。那么,是可读性优先还是存储效率优先?

在存储空间不够的早期,人们遵循一些代码习惯,通常是存储效率优先。比如枚举直接存个数字。在越来越重视可读性的今天,人们开始倾向于存储字符串。比如危急程度,如果 4 代表高危,那么原来会存储 4 ,现在会存储 HIGH。当然,如果需要用枚举来分页排序的话,还是需要用数字。
如果数据量不大,建议使用枚举或字符串,可读性良好,也不会占用太多空间;如果数据量非常大,那么用数字代号能够节省更多空间。毕竟成本是一个很重要的考虑因素。数字代号的可读性不佳,可以用代码和工具来补偿。

时间类型:时间戳与日期类型

时间有两种表示法:时间戳与日期类型。时间戳是一个相对的概念,是指此刻距从 1970 年 8 月 1 日零点开始的毫秒数。日期则是一个绝对的描述。 比如 2021 年 12 月 29 日 8 点 0 分 0 秒, 换成时间戳是1640476800000, 日期则根据格式,比如 2021-12-29 08:00:00。

通常,创建日期和修改日期用日期格式,因为通常只用于查看和修改,不会用于其它计算用途;而业务时间,比如下单时间、支付时间等则用时间戳,因为用时间戳查询范围会更方便,在前台展示时间,只要应用程序做一个转换即可。

此外,日期存在时区问题。如果要国际化,那么存储日期存在国际化兼容问题,最好能存储为时间戳。

大对象存储

文件、视频、音频等大数据对象,数据对象本身可以放在某个分布式存储系统里,而在数据库里可以放一个引用地址字段。比如文件可以在数据库里放一个 MD5 字段和一个引用地址 protocol://path/to/xxx, 这里的 path/to/xxx 可以从 MD5 中提取出来;音视频则可以在数据库里放一个 URL 字符串,或者使用特定协议的字符串。

若有必要,大数据存储可以使用压缩算法。压缩算法通常使用 gzip。

外键设计

外键一般用于关联查询。对于一对一的情形,只要用各表的主键 ID 进行关联即可。比如 订单表主键 order_id, 订单扩展表主键 order_ext_id,则在订单扩展表里冗余一个 order_id 即可。

对于一对多的情形,有两种方案:

  • 在多的那方冗余一的主键ID。比如订单表主键 order_id,则在商品表冗余这个字段即可,就可以查到一个订单的所有商品。在关系型数据库里很常用,比如 MySQL。
  • 在一的那方冗余多的主键ID。比如商品表主键 item_id,则在订单表里冗余 item_id。这种情形适用于返回订单的基本概况。如果要查商品详情,则可以根据取出的 item_id 去批量查询具体商品的详情。在关联查询支持不作为重点的存储系统里比较常用,比如 Mongo,HBase,ES 等。

这里涉及到业务。就是根据一查多和根据多查一。如果需要根据多查一,则第二种方式不太可行;如果根据一查多,则第一种方式需要很好地支持关联查询操作(主要是性能考虑)。

通常的做法是在多的一方容易一的主键值。

字段冗余设计

字段冗余设计,通常是为了性能考虑,直接从一个表里查到所有数据,而不是还需要在多张表里穿梭。

字段冗余设计尤其要考虑依赖变更处理。当在一个表里冗余字段时,这个字段如果依赖业务变更,则该表的这个字段也需要进行变更。如果这个字段的变更同步成本很大,则需要考虑冗余是否适当,利弊如何。

建议不要冗余频繁变更的字段,而是冗余变更非常少的字段。比如主键 ID 是一个很适合冗余的字段,一旦确定几乎不会更改(除非迁移数据); 名称则是不太适合冗余的字段,因为名称易变。

次要字段的存储

关键字段、主要字段、常用字段肯定要存储在表里。那么诸如名称、类型、属性等相关的纯属信息类的次要字段,或者是为本系统所用到的来自外部系统的字段,是否要一并存储呢?

  • 是否快照信息。如果是快照信息,则需要存储在表里;如果需要实时,则可以从接口里查询。
  • 是否需要搜索。如果需要搜索,则需要存储在表里。当然,如果有额外的搜索组件(比如 ES),则可以考虑不存在表的单独字段里,存在扩展字段里,然后同步到搜索组件。

何时违反数据库范式

数据库范式直接会决定产生的数据质量。通常不会违反数据库范式,但有些情形下,会通过冗余字段来提升性能。

空与默认值

  • 对于重要字段,遵循“严格”准则,设置必须非空,避免脏数据进入而无所察觉;
  • 对于次要字段,建议设置默认值,防止无意义的字符 null 进入(有可能会导致应用程序报错)。

设计衡量

如何衡量一个存储设计的质量的呢? 主要有如下考量目标:

  • 完整性和一致性:是否能够完全满足业务需求; 数据模型和质量是否完整、准确、一致;满足设计约束;避免脏数据;
  • 性能和稳定性: 在大数据量情形下,查询和操作的效率是否在用户可接受范围内;
  • 可扩展性: 当新增业务需求,或需求发生变化时,是否容易支持;
  • 可复用性: 当新增业务需求,或者接入新的业务方,是否能够低改动甚至无改动支持。

可以从两个层面来推敲:数据层面和业务层面。

从数据层面来看,可以推演在此基础上产生和容纳的数据质量。

  • 数据约束:数据是干净、有效、完整的;不会容纳脏数据进入(或者宽松一点,允许极少量的不影响业务和整体的脏数据);
  • 数据组织:主要考虑性能、可扩展性、一致性和可维护性。

从业务层面来看,可以推演在此基础上产生的算法与流程的复杂性。比如拿到某个用例和业务场景:

  • 是否容易实现;避免 workaround 方案;
  • 流程是否清晰直观;避免拐弯抹角;
  • 当业务在某个维度发生变化时,是否容易支持(是否要新增字段和流程);
  • 当业务在某个细节发生变化时,是否要做特殊处理;
  • 当要关联不同业务或场景的数据时,是否容易扩展、修改和维护;
  • 是否具备容纳不同来源、不同类型的业务数据的能力。

对于技术重构来说,需要走查现有所有场景,审查新设计是否能够满足已有的所有业务场景。如果有任一不满足,则需要进行优化和完善,或者舍弃不太重要的业务场景。

实战经验

关系型数据设计

业务型/事务型应用通常采用关系型数据库实现存储设计,通过规范化范式达成数据的完整性和一致性,尽可能少的冗余。领域建模是关系型存储设计的前置工作。

采用关系型数据库进行存储设计时,重点有:

  • 数据库设计很大程度上是领域模型的表达和呈现;因此领域模型的质量决定了数据库设计完成后是否需要做频繁的变更;
  • 尽可能采用规范化的关系范式,除非有足够理由打破范式。
  • 仔细设计实体映射关系,尤其是一对多关系时;仔细设计外键引用。通常是多的方引用一的方的主键作为外键。
  • 仔细设计字段名称、类型。字段具有单一含义,名称尽量贴切而具有描述性;主键类型通常采用自增的 bigint;字符串尽可能采用VARCHAR;枚举采用字符串更易理解;日期采用Date;增加极少量的扩展字段;加上字段注释COMMENT。熟悉和使用字段设计套路可提升设计效率。
  • 根据业务查询和更新场景,仔细设计好索引。设计组合索引时,区分度高的字段放前面。索引应少而实用,覆盖性高。熟悉和使用索引设计套路可提升设计效率。
  • 考虑运维和排查问题的需要。比如时间采用日期格式比时间戳格式更直观可读。
  • 寻找经验丰富的高级开发者和DBA给出中肯的Review意见并进行完善。

大数据存储设计

海量数据的搜索/导出/计算应用通常采用大数据存储,比如 ES, Hbase 等。

ES(Elasticsearch)是水平可扩展的可靠的分布式文档存储/查询/分析系统,通常用于海量数据的准实时联合搜索与分析,提供了RestFul接口风格的API。相比关系型数据库,ES的优点在于可以存储文档和对象的完整体,适合多字段的灵活的联合搜索和全文搜索;ES不适合于事务型应用以及对数据丢失零容忍的应用。通常使用DB+ES的组合,DB用于主存储,ES用于准实时联合搜索。

Hbase适用于海量数据的存储,设计合适的 rowkey 非常重要,通常关乎性能、吞吐量和负载均衡。比如订单详情通常是获取单个或少量订单的详情,Rowkey设计更注重负载均衡,高位作为散列字段; 订单导出则需要批量获取大量订单的所有详情,Rowkey设计既要注重负载均衡,也要充分考虑Rowkey排序的特性,保证大批量获取数据的效率。这样,订单导出的Rowkey可以业务属性来设计,比如店铺ID+订单号,或买家ID+订单号。当然,这种做法的不足在于,对于某些频繁导出大订单量的VIP商家,导出的热点就常常分布在少数的Region上。可以统计下导出的店铺分布及导出报表行数量,越分散,这种Rowkey设计越有利。

小结

存储设计是软件系统的基石。既涉及到宏观设计(业务建模、数据组织、范式、索引、关联设计、冗余、分表分库分片等),也涉及到各种细节(命名、类型、格式、空值等)。需要细致耐心地推敲。

存储设计涉及到大量的微决策。如何判断这些微决策是合理的呢? 一个基本准则是:是否足够必要。即“奥卡姆剃刀定律”:如无必要,勿增实体。可以列举出必要性的核心考量因素,逐一审查是否合理。

参考资料