数据工程设计模式——数据库与事务型数据

49 阅读17分钟

引言(Introduction)

本章将介绍关系型数据库分布式 NoSQL 数据库,并探讨在实际用途中关于视图(views) 、**二级索引(secondary indexes)**等的用例与设计模式。同时,我们还会在传统 RDBMS 与现代分布式 NoSQL 数据库的语境下,引入 ACID 事务的相关概念。

结构(Structure)

本章涵盖以下主题:

  • 认识关系型数据库
  • 分布式数据库简介
  • 数据库视图
  • 主键与二级索引
  • 传统 RDBMS 中的 ACID 事务
  • 分布式数据库中的事务

目标(Objectives)

在本章结束时,我们将覆盖基于 RDBMSNoSQL 的应用常见设计模式。读者将理解创建视图索引的利弊取舍;我们也将讨论数据库事务的重要性,以及如何借助事务来保障数据质量

认识关系型数据库(Understanding relational databases)

正如上一章所述,我们使用关系型数据库来存储结构化数据,并以的形式表示。关系型数据库软件提供了大量特性,帮助用户解决以下问题:

  • 借助 ACID 事务实现数据一致性
  • 定义多种数据对象及其相互关系
  • 创建视图索引以更快访问数据
  • 使用 SQL 编写并执行复杂查询(过滤、聚合等)

接下来我们将结合现代业务用例,讨论上述数据库特性。

分布式数据库简介(Introduction to distributed databases)

达美乐披萨(Domino’s Pizza)的 CEO 曾打过一个著名比方:达美乐是一家配送披萨的科技公司。这句话说明:现代企业高度依赖底层技术开展日常运营。这也意味着,一旦底层数据库系统故障,将直接带来营收损失。如果数据库仅运行在单台计算机上,该机器的故障就会导致不可用。为避免这种情况,现代企业采用分布式数据库:在后端使用多台机器;当其中一台发生故障,其他机器可继续对外提供服务。

分布式数据库的例子包括 MongoDB、Couchbase Server、Redis、Amazon Aurora DB 等。后续章节中,我们将基于分布式数据库讨论高可用容错负载均衡等数据工程模式。

数据库视图(Database views)

数据库视图是对数据库表中(通常)某个数据子集逻辑表示。通过该逻辑表示,用户可以在不暴露表中无关细节的情况下,仅访问特定用例所需的数据子集。利用视图进行数据过滤主要有两类用途:

  1. 聚焦特定子集,例如生成某类报表;
  2. 配合基于角色的访问控制(RBAC) ,实现受限授权

沿用前几章的电商网站示例:
假设网站在美国多个州发货,每个州都有自己的分发中心。此时,库存表可包含三列:产品 ID分发中心 ID可用数量,如下所示:

CREATE TABLE inventory (
    ProductId VARCHAR(64),
    DistCenterId VARCHAR(64),
    Quantity INT
);

若每个分发中心的运营经理需要每周生成库存报告,查看哪些库存不足并上推补货指令,则可执行如下查询:

SELECT ProductId, Quantity 
FROM inventory 
WHERE DistCenterId = 'NY';

要在数据库上执行该查询,纽约分发中心的运营经理需要对 inventory 表具有只读权限

GRANT SELECT ON inventory 
TO 'opmgr_ny';

如此一来,该经理也能访问其他分发中心的库存信息。若数据安全团队希望将访问限定在各自分发中心范围内,数据库管理员可创建仅面向纽约中心的视图,在视图中固化 WHERE 条件:

CREATE VIEW inventory_ny AS
SELECT ProductId, Quantity
FROM inventory
WHERE DistCenterId = 'NY';

接着,仅对视图 inventory_ny 授予 SELECT 权限即可,使该经理无法访问其他分发中心的数据:

GRANT SELECT ON inventory_ny
TO 'opmgr_ny';

最终,运营经理在生成报告时只需对视图查询,无需再写 WHERE 子句,因为过滤已在创建视图时定义:

SELECT ProductId, Quantity 
FROM inventory_ny;

在典型的传统 RDBMS 中,视图默认不存储实际数据;它们以元数据形式保存定义并引用原始表。因此,当表中原始数据更新后,视图返回的也是最新结果

借助物化视图(materialized view)可以进一步优化数据访问:物化视图会将属于视图的数据持久化存储,与原表数据分开。下一章我们将讨论物化视图,以及如何在普通视图物化视图之间做选择。

注:视图的定义可以包含更复杂的逻辑,如跨表 JOIN、聚合查询等。这样数据库管理员仅需在创建视图时编写一次复杂逻辑,应用开发者后续的查询即可更为简单。

主键与二级索引(Primary and secondary indexes)

数据库索引是让 SELECT 查询跑得更快的主要工具。索引利用诸如 B+ 树、跳表(skip list) 等数据结构,在不遍历全表的情况下实现快速查找与范围扫描。维护索引需要消耗 RAM、磁盘空间、磁盘带宽、CPU 等资源;但借助索引,查询复杂度常可由 O(n) 显著降为 O(log n)

主键索引(Primary indexes)

继续商品目录的示例:用户希望以产品 ID为输入,从数据库查到名称、品牌、价格等详情。沿用上一章的表结构:

CREATE TABLE product (
    Id VARCHAR(64),
    Name VARCHAR(255),
    Brand VARCHAR(128),
    Price INT
);

上例中尚未声明主键,但我们希望以 Id 作为主键。可修改为:

CREATE TABLE product (
    Id VARCHAR(64) PRIMARY KEY,
    Name VARCHAR(255),
    Brand VARCHAR(128),
    Price INT
);

注:主键(primary key)是在该表所有行中唯一的字段。

在 MySQL 中,即使未显式声明 NOT NULL,对主键字段也会隐式生效,并基于 Id 创建主键索引。数据库即可用该主键索引高效处理按 ID 查详情的查询,例如:

SELECT Name, Brand, Price
FROM product
WHERE Id = 'Id-1021';

有了主键索引,查询能快速定位到 Id-1021 的记录。

注:可以创建多字段主键,以保证多个字段组合的全表唯一性。

二级索引(Secondary indexes)

二级索引建在非主键字段上。和主键索引类似,二级索引也用于在以二级键为条件时加速点查范围扫描。二级键是对象的其他属性(如 NameBrandPrice 等),它们未必唯一

设想用户想按价格升序列出智能手机。我们在产品表里新增 Type 字段,手机的类型值为 smartphone

CREATE TABLE product (
    Id VARCHAR(64) PRIMARY KEY,
    Name VARCHAR(255),
    Brand VARCHAR(128),
    Price INT,
    Type VARCHAR(32)
);

为显示按价格升序的手机清单,创建如下二级索引:

CREATE INDEX type_price_index
ON product(Type, Price);

对应查询:

SELECT Price, Name, Brand
FROM product
WHERE Type = 'smartphone'
ORDER BY Price ASC;

若用户还想加上价格区间过滤,也可利用同一索引:

SELECT Price, Name, Brand
FROM product
WHERE Type = 'smartphone' AND Price > 100 AND Price < 400
ORDER BY Price ASC;

在上述两条查询中,WHERE Type = 'smartphone' 让执行器只需遍历类型为手机的那段有序索引区间;在第二条查询里,再叠加 Price 范围过滤,使遍历的数据子集更小。

注:示例中的 ORDER BY Price ASC 在很多引擎上并非必需,因为 type_price_index 已按 Type, Price 升序组织,能直接满足排序。

注:建在多个字段上的二级索引称为复合索引(composite index) 。复合索引允许对多列同时过滤,并在该组过滤条件下取得良好性能。

二级索引中键顺序的重要性(Importance of index key order)

回顾上面的二级索引示例:索引能大幅减少查询需要遍历的条目数。查询性能与需要遍历的条目数成正比,而用户体验又直接取决于查询性能。对数十亿条记录的表而言,索引设计不当会让性能比正确索引差几个数量级

来看一个“不佳索引”的例子。假设产品表新增 Rating 字段,表示买家给该商品的平均评分(四舍五入到整数)。

延续手机用例:用户希望按评分升序查看评分 ≥ 4 的智能手机清单(评分 1–5)。由于有两个过滤条件,索引应包含这两列。先在表中加入字段:

CREATE TABLE product (
    Id VARCHAR(64) PRIMARY KEY,
    Name VARCHAR(255),
    Brand VARCHAR(128),
    Price INT,
    Type VARCHAR(32),
    Rating INT
);

对应查询:

SELECT Id, Rating
FROM product
WHERE Type = 'smartphone' AND Rating >= 4
ORDER BY Price ASC;

设产品总量为 1000 万,其中仅 1000 件是手机,其余为非手机;且所有产品的评分在 1–5 之间均匀分布。那么满足条件的只有约 400 条。

有两种索引可选:

选项 1: (Rating, Type)

CREATE INDEX rating_type_index
ON product(Rating, Type);

选项 2: (Type, Rating)

CREATE INDEX type_rating_index
ON product(Type, Rating);

若采用选项 1:执行器会先遍历评分 ≥ 4 的所有产品,再按 Type 过滤手机。这样需要扫描约 400 万 条索引项,才能得到那 400 条目标记录——明显低效。(图 9.1:非最优二级索引)

image.png

若采用选项 2:执行器可直接跳到 Type = 'smartphone' 的区间,再在其中找 Rating ≥ 4 的记录,仅需遍历约 400 条——显著更优。(图 9.2:最优二级索引)

image.png

注:相较于不等(如 >=),查询引擎通常对等值条件(=)的利用效率更高;因此把选择性高、可等值匹配的列放在复合索引的前缀位置,往往能取得更好性能。

传统 RDBMS 中的 ACID 事务(ACID transactions in traditional RDBMS)

事务语义的支持,是关系型数据库提供的第一道故障安全网。事务的基本属性为:

  • Atomicity(原子性
  • Consistency(一致性
  • Isolation(隔离性
  • Durability(持久性

下面以用户在结账确认订单为例,理解事务的 ACID 属性。电商网站在最终确认订单时需要执行以下步骤:

  1. 检查库存数量是否充足;若不足,则不确认订单。
  2. Orders 表中加入该订单记录。
  3. Inventory 中扣减相应数量。
  4. 复核库存:若最终数量 = 初始数量 − 订单数量,则确认订单;否则不确认。

为简化示例,假设一个订单只包含一种商品(数量可变)。相关表结构如下:

CREATE TABLE inventory (
    ProductId VARCHAR(64),
    Quantity INT
);
CREATE TABLE orders (
    Id VARCHAR(64),
    ProductId VARCHAR(64),
    Quantity INT
);

基于上述表结构,下面的代码(作为存储过程实现,即过程 9.1)体现了订单最终确认的步骤:

DELIMITER //
CREATE PROCEDURE finalizeOrder(
    IN OrderId VARCHAR(64),
    IN OrderProductId VARCHAR(64),
    IN OrderQuantity INT
)
BEGIN 
  DECLARE qty INT;
  DECLARE final INT;
  DECLARE newQty INT;
  -- Start the transaction.
  START TRANSACTION;
  -- Fetch current quantity from the inventory.
  SELECT Quantity INTO qty FROM inventory WHERE ProductId = OrderProductId;
  IF qty < OrderQuantity THEN
    -- Don’t finalize the order.
    ROLLBACK;

  ELSE
    -- Store the order details to the orders table.
    INSERT INTO orders (Id, ProductId, Quantity) 
    VALUES (OrderId, OrderProductId, OrderQuantity);
    -- Decrement the quantity in the inventory
    SET newQty = qty - OrderQuantity;
    UPDATE inventory
    SET Quantity = newQty
    WHERE ProductId = OrderProductId;
    -- Validate the quantity from inventory before finalizing the order.
    SELECT Quantity INTO final FROM inventory WHERE ProductId = OrderProductId;
    IF final = newQty THEN
      -- The quantities match. Safe to finalize the order using COMMIT.
      COMMIT;
    ELSE
      -- The quantities don’t match. Rollback the transaction.
      ROLLBACK; 
    END IF;
  END IF;
END

过程 9.1:订单最终确认

现在结合上例来理解数据库事务的 ACID 属性:

  • 原子性(Atomicity) :事务作为一个整体执行,即便包含多个步骤,也只能全部成功全部失败,不留任何副作用。在本例中,有两步会修改数据库:向 orders 表插入订单、更新 inventory 库存。若数据库软件在执行第一步后崩溃,数据库会回滚该步已做的更新以确保原子性。
  • 一致性(Consistency) :事务完成(无论成败)后,数据必须处于正确状态。数据库中的商品总库存应与实际一致:若订单确认成功,库存应当减少相应数量;若失败,库存不应被扣减。
  • 隔离性(Isolation) :数据库应当将每个事务相互隔离地执行。即便系统内有其他事务并发执行,它们也不应互相产生外部可见的影响。在本例中,若其他事务在期间修改了该商品库存,当前事务检测到不一致应失败(随后可重试)。
  • 持久性(Durability) :一旦事务提交成功,其结果应当持久保存。任何系统事件(数据库崩溃、主机重启等)都不应导致已确认订单的数据丢失。系统从故障中恢复后,订单信息应仍可查询。

这些 ACID 定义诞生于数十年前,至今仍然有效。不过,它们最初是针对单机数据库系统提出的。在现代分布式数据库中,这些属性的实现与含义已发生了重要变化。

接下来我们将讨论分布式数据库中的事务概念。

分布式数据库中的事务(Transactions in distributed databases)

如前所述,分布式数据库在多台计算机上运行,以实现容错负载均衡。为保证容错,同一份数据会在多台计算机(分布式系统术语称为节点)上保存多份副本。每个节点都有自己的计算(CPU)、内存(RAM)与二级存储(磁盘),因而各自可以存储并对外提供其负责的数据。

本章只关注分布式数据库中的事务性质;而关于数据复制与分区的工程模式,将在第 1 章《理解数据工程》中讨论。

由于数据在多个节点之间分区与复制,传统 ACID 属性在含义上发生了变化。例如,在传统 RDBMS 中,当事务产生的数据已写入磁盘时,即认为持久(Durable) 。若磁盘故障即视为数据丢失,传统 RDBMS 无法应对。而在分布式数据库中,事务数据会复制到多台节点上:只有当数据被写入至少 n 台节点(而非仅 1 台)时,事务才被视为持久,这样即便同时n−1 台节点失效,也能避免数据丢失。

不同的分布式数据库在分布式事务语境下,对 ACID 属性有不同实现与定义,因此不存在统一的解释。本章聚焦于现代分布式数据库中两种常见模式。

注:理论上,分布式数据库可以强制遵循传统 ACID 定义,但通常带来明显的性能开销。因此许多系统采用适配分布式事务场景的变体

现代分布式数据库在性能(Performance)可用性(Availability)分区容忍性(Partitioning)三者之间寻求权衡,这一权衡即著名的 CAP 定理:任何分布式系统最多只能同时保证其中两项。于是,多数数据库在保证分区能力与可用性的同时,往往选择放宽一致性

MongoDB 中的持久性(Durability in MongoDB)

现代应用常见写入密集工作负载,磁盘带宽易成瓶颈,导致写入变慢。为此,MongoDB、Couchbase Server 等 NoSQL 数据库会弱化持久性:即便尚未写盘,也会向应用返回成功,此时数据仅写入内存数据结构。在数据库服务运行期间,读请求可直接由内存数据服务,但不具备持久性:若在落盘前崩溃或重启,数据会丢失。数据库会定期把数据刷新到磁盘;但对分布式数据库而言,仅此还不够——在多节点 MongoDB 集群中,数据还需跨节点复制,才称得上“真正持久”。

MongoDB 提供两个相关参数:写关注(write concern)日志(journaling)开关。通过它们可以在持久性、性能、可用性之间取舍。以下以“订单最终确认”为例:支付成功后,需要将订单详情写入数据库,供发货部门使用。假设我们有一个 3 节点 的 MongoDB 集群:1 主 2 从(P-S-S)

注:MongoDB 的**副本集(replica set)**是在不同节点上保存的数据副本,用复制提供高可用。

多数写入 + 开启日志(Write to majority with journaling)

MongoDB 默认配置通过“写入多数副本”提供较强的持久性保证。在 P-S-S 架构下,主节点 + 至少 1 个从节点写入成功即视为“多数”。

当订单确认时,写请求先到主副本集:主节点更新内存并将变更转发至两个从节点;只有当数据已落盘其中一个从后,才向客户端返回确认。此后即便节点失效,数据仍可从从节点读取,不会丢失。

写入所有副本集(Write to all replica sets)

默认行为可应对单节点失效。但若同时失效两台:例如和那个已写入订单的从节点都宕机,则可能丢数据(无法确认另一从是否也已写盘)。为处理多节点失效,可将 write concern 设为 3,要求写入三份都成功才确认。代价是:若订单确认时任一副本集不可用,则写入会失败。因此设置 write concern 时需谨慎平衡可靠性可用性/延迟

DynamoDB 的最终一致性(Eventual consistency in DynamoDB)

DynamoDB 是 AWS 上广受欢迎的分布式 NoSQL 数据库。受其分布式特性影响,DynamoDB 采用一种不同的一致性变体:最终一致性(eventual consistency) 。它不等价于传统意义上的一致性,但可在更好的性能下满足大量应用需求。

用表 9.1 对比传统 RDBMS 与 DynamoDB 的一致性差异:

表 9.1:理解 DynamoDB 的最终一致性

操作传统 RDBMSDynamoDB
写入值 = 10写成功写成功
读取值返回 10返回 10
更新值 = 20写成功写成功
紧接更新后读取返回 20可能返回 10 或 20
稍后再读取返回 20返回 20

继续库存示例:在传统 RDBMS(不启用事务)中,若更新成功,则之后的读取必定返回最新值。而在 DynamoDB(不启用事务)中并不保证这一点。由于其分布式特性,当更新复制到多数节点即视为成功;为保证可用性与性能,不会等待复制到所有节点。因此,如果后续读取由尚未收到最新写入的节点提供,便可能返回旧值

注:系统达到“最终一致”所需的确切时间未知,因此应用可通过强一致读取(strongly consistent read)来规避。

DynamoDB 默认读取是最终一致的;在 GetItem、Query、Scan 等 API 中,将 ConsistentRead 设为 true 可执行强一致读取。但需注意两点开销:更慢,且计费约为最终一致读取的两倍

注:最终一致性仅影响更新后的读取。对于初次写入成功后的读取,DynamoDB 仍保证读已提交(read-committed) :即读取到已提交值,即便非强一致读。

结语(Conclusion)

本章回顾了数据库视图的价值(简化 SQL、基于过滤授权等),介绍了主/二级索引及其性能收益,并强调了复合二级索引键序对性能的关键性。随后讨论了ACID 事务属性以及如何在传统 RDBMS(如 MySQL) 中利用事务保证数据质量

同时,通过 MongoDBDynamoDB 两个示例,我们看到在分布式 NoSQL 语境下,ACID 属性的语义发生了变化:不同系统在 API、术语与实现上差异明显,说明在分布式数据库世界里不存在唯一标准定义。许多系统会放宽一致性以换取更好的性能与可用性

下一章将引入数据分析与商业智能(BI)的概念,理解数据仓库在分析问题中的作用,并深入探讨仓库特性,如物化视图、列式存储、星型/雪花模型等。