MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

ACID 特性在 NoSQL 数据库中的应用与差异

2023-06-101.8k 阅读

ACID 特性概述

在深入探讨 ACID 特性在 NoSQL 数据库中的应用与差异之前,先来回顾一下 ACID 特性的基本概念。ACID 是数据库事务处理的四个基本特性的缩写,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

原子性(Atomicity)

原子性要求事务中的所有操作要么全部成功执行,要么全部不执行。就好比一个银行转账操作,从账户 A 向账户 B 转账 100 元,这个操作包含从账户 A 扣除 100 元以及向账户 B 增加 100 元两个步骤。如果在执行过程中出现故障,例如网络中断或者系统崩溃,原子性确保这两个步骤要么都完成,要么都回滚,不会出现账户 A 钱扣了但账户 B 没收到钱的情况。

一致性(Consistency)

一致性确保事务执行前后,数据库的完整性约束没有被破坏。比如在一个库存管理系统中,库存数量不能为负数。当进行商品出库操作时,事务必须保证在操作完成后,库存数量仍然满足非负的约束条件。如果因为某种原因导致库存数量变成负数,那么就违反了一致性原则。

隔离性(Isolation)

隔离性规定了并发执行的事务之间应该相互隔离,不会互相干扰。想象有两个事务同时对一个账户进行操作,一个事务是查询账户余额,另一个事务是向账户存钱。如果没有隔离性,查询事务可能会读取到存钱事务尚未提交的数据,导致查询结果不准确。隔离性通过不同的隔离级别来控制并发事务之间的可见性,常见的隔离级别有读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。

持久性(Durability)

持久性保证一旦事务提交,其对数据库所做的修改就会永久保存下来,即使系统发生故障(如停电、硬件故障等)也不会丢失。例如,在银行转账事务提交后,即使银行系统突然崩溃,转账的结果也应该是有效的,不会因为系统故障而恢复到转账前的状态。

ACID 在传统关系型数据库中的实现

传统的关系型数据库(如 MySQL、Oracle 等)对 ACID 特性提供了很好的支持,下面以 MySQL 为例来看看其具体实现方式。

原子性的实现

MySQL 通过日志机制来实现原子性。在执行事务时,所有的操作都会先记录到日志中,这些日志包括重做日志(Redolog)和回滚日志(Undolog)。当事务执行过程中出现故障时,可以根据回滚日志撤销未完成的操作,保证事务的原子性。例如,在执行一个插入操作时,先将插入操作记录到回滚日志中,如果出现故障,可以利用回滚日志撤销该插入操作。

一致性的实现

MySQL 通过约束机制来保证一致性。数据库表可以定义各种约束,如主键约束、外键约束、检查约束等。当执行事务时,MySQL 会检查这些约束是否被满足,如果不满足则回滚事务。例如,在一个员工表中,定义了员工编号为主键,当插入一条新员工记录时,如果新记录的员工编号已经存在,MySQL 会因为违反主键约束而回滚插入操作,从而保证数据的一致性。

隔离性的实现

MySQL 通过锁机制和多版本并发控制(MVCC)来实现隔离性。不同的隔离级别对应不同的锁策略和 MVCC 机制。在读未提交隔离级别下,事务可以读取到其他事务未提交的数据,几乎不使用锁;在读已提交隔离级别下,使用锁来保证事务只能读取到已提交的数据;可重复读隔离级别则通过 MVCC 来保证在同一事务内多次读取相同数据时结果一致;串行化隔离级别则是通过对事务进行串行执行,完全避免并发问题。

持久性的实现

MySQL 通过将重做日志持久化到磁盘来保证持久性。当事务提交时,MySQL 会将重做日志刷新到磁盘,这样即使系统崩溃,在重启后可以通过重做日志恢复到事务提交后的状态。

NoSQL 数据库概述

NoSQL(Not Only SQL)数据库是一类非关系型数据库,与传统关系型数据库相比,它具有高可扩展性、高性能、灵活的数据模型等特点。NoSQL 数据库主要分为以下几类:

键值数据库(Key - Value Database)

键值数据库以键值对的形式存储数据,数据结构简单,读写速度非常快。典型的键值数据库有 Redis。例如,可以使用 Redis 存储用户信息,以用户 ID 作为键,用户信息(如姓名、年龄等)作为值。这种数据库适用于对读写性能要求极高,数据结构相对简单的场景,如缓存、会话管理等。

文档数据库(Document Database)

文档数据库以文档的形式存储数据,文档通常采用 JSON 或 BSON 格式。MongoDB 是最具代表性的文档数据库。文档数据库适合存储半结构化或非结构化的数据,比如博客文章、用户评论等。它的灵活性在于可以根据需求动态地添加或修改文档的字段,无需像关系型数据库那样预先定义表结构。

列族数据库(Column - Family Database)

列族数据库以列族为单位存储数据,数据按列族进行组织和存储。HBase 是典型的列族数据库。它适用于海量数据的存储和处理,在大数据领域应用广泛。例如,在存储物联网设备产生的大量传感器数据时,使用列族数据库可以高效地存储和查询不同类型传感器的数据。

图形数据库(Graph Database)

图形数据库专门用于存储和处理图形结构的数据,如社交网络、知识图谱等。Neo4j 是著名的图形数据库。它通过节点、边和属性来表示和存储数据,能够高效地处理复杂的图形关系查询,比如查找社交网络中两个用户之间的最短路径。

ACID 特性在 NoSQL 数据库中的应用与差异

不同类型的 NoSQL 数据库对 ACID 特性的支持和应用方式存在差异,下面分别从原子性、一致性、隔离性和持久性四个方面来分析。

原子性

  1. 键值数据库
    • Redis 的原子性:Redis 对单个操作提供原子性保证。例如,使用 SET 命令设置一个键值对,这个操作是原子的。在并发环境下,多个客户端同时执行 SET 操作不会出现部分成功的情况。代码示例如下:
    import redis
    
    r = redis.Redis(host='localhost', port=6379, db = 0)
    r.set('key1', 'value1')
    
    但是,Redis 对于多个操作组成的事务,默认情况下并非严格的原子性。Redis 提供了 MULTIEXEC 命令来支持事务。MULTI 用于标记事务开始,EXEC 用于执行事务中的所有命令。不过,如果在 EXEC 执行之前,其中某个命令出现语法错误,整个事务并不会回滚,而是会继续执行后续命令。例如:
    r.multi()
    r.set('key2', 'value2')
    r.incr('key2')  # key2 不是数字,这里会有语法错误
    r.exec()
    
    在这种情况下,key2 会被成功设置为 value2,但 incr 命令会失败,而整个事务不会回滚。如果要保证严格的原子性,可以使用 WATCH 命令来监控键的变化,在事务执行前如果被监控的键发生变化,事务将被取消。
  2. 文档数据库
    • MongoDB 的原子性:MongoDB 对单个文档的写入操作是原子的。例如,更新一个文档的某个字段,这个操作要么全部成功,要么全部失败。代码示例:
    from pymongo import MongoClient
    
    client = MongoClient('mongodb://localhost:27017/')
    db = client['test_database']
    collection = db['test_collection']
    document = {'name': 'John', 'age': 30}
    result = collection.insert_one(document)
    
    当使用 update_oneupdate_many 方法更新文档时,如果更新操作失败(例如违反了某些约束),整个操作会回滚,保证了单个文档操作的原子性。但是,对于跨多个文档的操作,MongoDB 在 4.0 版本之前不支持事务,无法保证原子性。从 4.0 版本开始,MongoDB 引入了多文档事务支持,通过 start_sessionwith_transaction 方法来实现跨文档操作的原子性。示例代码如下:
    with client.start_session() as session:
        def callback(session):
            collection1 = db['collection1']
            collection2 = db['collection2']
            collection1.insert_one({'data': 'data1'}, session = session)
            collection2.insert_one({'data': 'data2'}, session = session)
        session.with_transaction(callback)
    
  3. 列族数据库
    • HBase 的原子性:HBase 对单个单元格(Cell)的读写操作是原子的。例如,在一个表中更新某个单元格的值,这个操作要么成功,要么失败。然而,对于多行多列的操作,HBase 通常不支持原子性。HBase 是为大规模分布式存储设计的,为了保证高可用性和扩展性,在跨行操作时没有像关系型数据库那样严格的原子性支持。不过,在一些特定场景下,可以通过使用协处理器(Coprocessor)来实现部分跨行操作的原子性,但这需要复杂的开发和配置。
  4. 图形数据库
    • Neo4j 的原子性:Neo4j 对单个事务中的所有操作提供原子性保证。无论是创建节点、添加边还是更新节点和边的属性,整个事务要么全部成功提交,要么全部回滚。例如:
    import org.neo4j.driver.*;
    
    public class Neo4jAtomicityExample {
        public static void main(String[] args) {
            Driver driver = GraphDatabase.driver("bolt://localhost:7687", AuthTokens.basic("neo4j", "password"));
            try (Session session = driver.session()) {
                session.writeTransaction(tx -> {
                    tx.run("CREATE (n:Person {name: 'Alice'})");
                    tx.run("CREATE (m:Person {name: 'Bob'})");
                    tx.run("CREATE (n)-[:KNOWS]->(m)");
                    return null;
                });
            }
            driver.close();
        }
    }
    
    在上述代码中,创建两个节点以及它们之间关系的操作在一个事务中,要么全部成功执行,建立起节点和关系,要么在任何一个操作失败时全部回滚,不会出现部分创建的情况。

一致性

  1. 键值数据库
    • Redis 的一致性:Redis 通常采用最终一致性模型。在主从复制架构下,当主节点写入数据后,会异步将数据复制到从节点。这就可能导致在某个短暂的时间内,从节点的数据与主节点不一致。例如,客户端在主节点写入数据后,立即从从节点读取,可能读到旧数据。不过,随着时间推移,从节点会逐渐同步主节点的数据,最终达到一致。Redis 提供了一些配置选项来调整一致性和性能的平衡,如 replica - read - only 配置项可以控制从节点是否只读,避免在数据不一致时读取到旧数据。
  2. 文档数据库
    • MongoDB 的一致性:MongoDB 支持多种一致性级别。默认情况下,MongoDB 提供的是最终一致性。在写操作时,可以通过设置 w 选项来调整一致性级别。例如,w = 1 表示只要主节点写入成功就返回,这可能导致在某些情况下从节点数据不一致;w = "majority" 表示等待大多数副本节点写入成功后才返回,这种情况下一致性更高,但会牺牲一定的写入性能。代码示例:
    collection.insert_one({'name': 'test'}, w = "majority")
    
    当进行读取操作时,也可以通过设置 readPreference 来指定读取的副本集成员,例如设置为 primaryPreferred 表示优先从主节点读取,以获取最新的数据,保证一致性。
  3. 列族数据库
    • HBase 的一致性:HBase 提供了强一致性保证。在 HBase 中,读操作会等待所有相关的 RegionServer 上的数据更新完成后才返回,确保读取到的数据是最新的。例如,当一个单元格的值被更新后,后续的读取操作一定能读到更新后的值。这是通过 HBase 的读写锁机制和数据版本控制来实现的。每个单元格都有一个时间戳作为版本号,写操作会增加版本号,读操作会根据版本号来获取最新的数据。
  4. 图形数据库
    • Neo4j 的一致性:Neo4j 提供了强一致性保证。在 Neo4j 中,所有的写操作都在事务中进行,事务提交时会确保所有相关的数据更新都已持久化。读操作只会看到已提交的事务结果,不会出现读取到未提交数据或不一致数据的情况。例如,在并发环境下,多个事务对图数据进行操作,Neo4j 会通过锁机制和事务管理确保每个事务看到的图数据状态是一致的,符合 ACID 中的一致性要求。

隔离性

  1. 键值数据库
    • Redis 的隔离性:Redis 的隔离性相对较弱。由于 Redis 单线程处理命令,在同一时间只有一个命令在执行,所以从命令执行角度看不存在并发干扰问题。但是,在主从复制和集群环境下,由于数据复制是异步的,可能会出现读取到旧数据的情况,这在一定程度上违反了隔离性的严格定义。例如,在主从复制环境中,主节点更新数据后,从节点尚未同步,此时从节点读取到的数据就是旧数据。Redis 没有像关系型数据库那样明确的隔离级别概念,但可以通过一些客户端库的扩展来实现类似隔离性的功能,如使用 WATCH 命令监控键的变化,在事务执行前如果键发生变化则取消事务,从而避免并发事务之间的干扰。
  2. 文档数据库
    • MongoDB 的隔离性:MongoDB 在 4.0 版本之前,对于并发操作的隔离性支持有限。由于其数据存储和读取的设计,可能会出现脏读(读取到未提交的数据)的情况。从 4.0 版本引入多文档事务后,MongoDB 可以通过设置不同的事务隔离级别来控制并发事务之间的隔离性。例如,在默认的读已提交隔离级别下,事务只能读取到已提交的数据,避免了脏读。代码示例:
    with client.start_session() as session:
        session.start_transaction(readConcern = ReadConcern.MAJORITY, writeConcern = WriteConcern.MAJORITY)
        # 执行事务操作
        session.commit_transaction()
    
    在上述代码中,通过设置 readConcernwriteConcern 来调整事务的读写一致性和隔离性。
  3. 列族数据库
    • HBase 的隔离性:HBase 通过读写锁来实现隔离性。在写操作时,会对相关的 Region 加写锁,防止其他写操作同时进行,避免数据冲突。在读操作时,会根据数据的版本号来确保读取到的数据是一致的。由于 HBase 是面向列族存储的,对于不同列族的操作可以并发进行,在一定程度上提高了并发性能。例如,在一个表中有多个列族,对一个列族的写操作不会影响其他列族的读操作,只要读操作的时间戳在写操作完成之后,就能保证读取到一致的数据。
  4. 图形数据库
    • Neo4j 的隔离性:Neo4j 提供了类似于关系型数据库的事务隔离机制,支持读已提交和可重复读隔离级别。在默认的读已提交隔离级别下,事务只能读取到已提交的数据,避免脏读。在可重复读隔离级别下,事务在整个生命周期内多次读取相同数据时,结果保持一致,避免不可重复读和幻读问题。例如,在一个事务中多次查询某个节点的属性,无论其他事务对该节点属性如何修改,在当前事务内查询结果始终不变。Neo4j 通过 MVCC(多版本并发控制)和锁机制来实现这些隔离级别,确保并发事务之间的隔离性。

持久性

  1. 键值数据库
    • Redis 的持久性:Redis 提供了两种持久化方式,RDB(Redis Database)和 AOF(Append - Only File)。RDB 是通过定期将内存中的数据快照保存到磁盘上,优点是恢复速度快,但可能会丢失最近一次快照之后的数据。AOF 则是将每个写命令追加到文件中,通过重写机制来避免文件过大,AOF 可以保证数据的持久性更好,即使系统崩溃,最多只会丢失最后一次写入操作的数据。可以通过配置文件来选择使用 RDB 还是 AOF,或者同时使用两者。例如,在 Redis 配置文件中,可以设置 save 900 1 表示在 900 秒内如果至少有 1 个键被修改,则进行 RDB 快照;设置 appendonly yes 来启用 AOF 持久化。
  2. 文档数据库
    • MongoDB 的持久性:MongoDB 通过日志机制和副本集机制来保证持久性。MongoDB 使用 WiredTiger 存储引擎,写操作会先记录到日志文件(journal)中,然后再持久化到磁盘。在副本集环境下,写操作会同步到多个副本节点,确保数据的冗余和持久性。例如,当主节点发生故障时,副本节点可以接替主节点继续提供服务,并且数据不会丢失。通过设置 wj 选项可以进一步控制写操作的持久性。w 选项用于指定写操作需要等待多少个副本节点确认,j 选项表示是否等待写操作记录到日志文件后再返回。例如,w = "majority" j = true 表示等待大多数副本节点将写操作记录到日志文件后才返回,这样可以保证更高的数据持久性。
  3. 列族数据库
    • HBase 的持久性:HBase 通过 HLog(Write - Ahead Log)来保证持久性。所有的写操作都会先记录到 HLog 中,然后再写入内存中的 MemStore。当 MemStore 达到一定阈值时,会将数据刷写到磁盘上的 StoreFile。如果系统发生故障,可以通过重放 HLog 来恢复未完成的写操作,保证数据的持久性。此外,HBase 的数据存储在 HDFS(Hadoop Distributed File System)上,HDFS 的多副本机制也进一步增强了数据的持久性,即使某个节点出现故障,数据仍然可以从其他副本节点获取。
  4. 图形数据库
    • Neo4j 的持久性:Neo4j 通过事务日志和存储引擎来保证持久性。在事务提交时,会将事务中的所有操作记录到事务日志中,然后再更新存储文件。Neo4j 的存储引擎(如 NeoStore)负责管理数据的持久化存储。即使系统崩溃,在重启后可以通过重放事务日志来恢复到事务提交后的状态,确保数据的持久性。同时,Neo4j 也支持备份和恢复机制,可以定期对数据库进行备份,以便在出现灾难性故障时能够快速恢复数据。

总结 ACID 特性在 NoSQL 数据库中的应用与差异对开发的影响

在后端开发中,理解 ACID 特性在 NoSQL 数据库中的应用与差异对于选择合适的数据库和设计高效、可靠的系统至关重要。

如果应用场景对读写性能要求极高,对一致性要求相对宽松,如缓存、实时统计等场景,键值数据库如 Redis 是不错的选择。虽然 Redis 的原子性和隔离性在某些情况下有局限性,但通过合理的设计和使用 WATCH 等机制,可以在一定程度上满足需求。

对于半结构化或非结构化数据存储,且对一致性和原子性有一定要求的场景,如内容管理系统、日志存储等,文档数据库如 MongoDB 较为合适。MongoDB 从 4.0 版本引入多文档事务后,在保证灵活性的同时,也能更好地满足复杂业务逻辑对原子性和一致性的需求。

当处理海量数据,且对数据一致性和持久性要求严格时,列族数据库如 HBase 是一个很好的选择。尽管 HBase 在跨行操作原子性方面存在不足,但在大规模分布式存储场景下,其高可用性和强一致性保证了数据的可靠性。

在处理复杂图形关系数据,且对事务完整性要求较高的场景,如社交网络分析、知识图谱构建等,图形数据库如 Neo4j 是首选。Neo4j 对 ACID 特性的全面支持,尤其是原子性和一致性,使得开发人员可以专注于业务逻辑的实现,而无需过多担心数据一致性和并发问题。

总之,在后端分布式系统开发中,根据具体业务需求,综合考虑 ACID 特性在不同 NoSQL 数据库中的应用与差异,合理选择数据库技术,能够构建出更加高效、可靠和可扩展的系统。