【问题标题】:Atomic transactions in key-value stores键值存储中的原子事务
【发布时间】:2010-11-08 18:01:34
【问题描述】:

请原谅术语中的任何错误。特别是,我使用的是关系数据库术语。

有许多持久键值存储,包括 CouchDBCassandra,以及许多其他项目。

反对它们的一个典型论点是它们通常不允许跨多行或多表的原子事务。我想知道是否有一个通用的方法可以解决这个问题。

以一组银行账户的情况为例。我们如何将钱从一个银行账户转移到另一个银行账户?如果每个银行账户都是一行,我们希望更新两行作为同一交易的一部分,减少一个的值并增加另一个的值。

一个明显的方法是有一个单独的表来描述事务。然后,将资金从一个银行账户转移到另一个银行账户只需在该表中插入一个新行。我们不存储两个银行账户中的任何一个的当前余额,而是依赖于汇总交易表中所有适当的行。然而,很容易想象这将是太多的工作。一家银行每天可能有数百万笔交易,而一个单独的银行账户可能很快就会有数千笔与之相关的“交易”。

如果基础数据自上次获取后发生更改,则许多(全部?)键值存储将“回滚”操作。可能这可用于模拟原子事务,然后,您可以指示特定字段已锁定。这种方法存在一些明显的问题。

还有其他想法吗?完全有可能是我的方法完全不正确,而且我还没有完全理解新的思维方式。

【问题讨论】:

  • CouchDB 不是键值,它是一个文档存储。

标签: couchdb transactions cassandra key-value


【解决方案1】:

如果您想以原子方式更新 单个 文档(关系术语中的行)中的值,则可以在 CouchDB 中执行此操作。如果其他竞争客户在您阅读后更新了同一个文档,则在您尝试提交更改时会出现冲突错误。然后,您必须读取新值、更新并重试提交。您可能需要重复此过程的次数不确定(如果存在 lot 的争用,则可能无限次),但如果满足以下条件,则可以保证数据库中的文档具有原子更新的余额你的提交永远成功。

如果您需要更新两个余额(即从一个账户转移到另一个账户),那么您需要使用一个单独的交易文档(实际上是另一个表,其中行是交易)和两个账户(在出)。顺便说一句,这是一种常见的簿记做法。由于 CouchDB 仅根据需要计算视图,因此从列出该帐户的交易中计算帐户中的当前金额实际上仍然非常有效。在 CouchDB 中,您将使用一个映射函数,该函数发出帐号作为键和交易金额(传入为正数,传出为负数)。您的 reduce 函数将简单地将每个键的值相加,发出相同的键和总和。然后,您可以使用 group=True 的视图来获取帐户余额,以帐号为键。

【讨论】:

  • 感谢您对此的解释。你说做组“还是很有效率的”。你能详细说明一下吗?对于高流量的关系数据库,通常的做法是对列进行非规范化。我可以想象 CouchDB 和其他数据库存储数据的方式大不相同,这意味着事务的分组可能更有效。但是你会用 10 个交易来分组吗? 100? 100,000?
  • CouchDB 使用 Map/Reduce 范例来查看数据库中的文档。由于 map 仅适用于更改的文档,因此其(时间)效率在文档总数上本质上是 O(1),但在更改文档数量上是 O(n)。减少的值被计算并存储在 b-tree 中。显然,所有具有更改的子文档的节点都需要重新计算。因此,运行归约可能更耗时。 CouchDB 已在生产环境中展示了数百万个文档,因此我认为在这种情况下这不会成为问题。
  • 谢谢。顺便说一句,我在一个社交网站工作。我们不打算在中期内切换到持久键值存储。当然,我们使用分片 MySQL 数据库服务器和 memcache。看起来我们繁忙的表已经看到了数亿行,但除此之外什么都没有。从您的回答来看,至少,CouchDB 似乎是专门设计用于处理我认为像我们这样的网站会出现的各种问题。不太令人惊讶,但仍然很高兴听到。我确信 CouchDB 和其他人会做得更好,偶尔会做得更糟。
  • 我想我会修改你的总结:“CouchDB 和其他人会做一些更好的事情和一些更糟糕的事情”。 CouchDB 的创建者 Damien Katz 经常说,如果您的数据将由文档(或卡片或页面等)描述,如果它是物理的,那么 CouchDB 是一个很好的匹配。如果不是(例如,您正在表示对象图)CouchDB 可能非常不适合。毕竟,它只是一个工具。
  • CouchDB 似乎只保证在单节点配置中运行时会发生冲突。
【解决方案2】:

CouchDB 不适合事务系统,因为它不支持锁定和原子操作。

为了完成银行转帐,您必须做几件事:

  1. 验证交易,确保源账户中有足够的资金,两个账户都处于打开状态、未锁定且信誉良好,等等
  2. 减少源账户的余额
  3. 增加目标账户的余额

如果在这些步骤之间对账户的余额或状态进行了更改,则交易在提交后可能会变得无效,这在此类系统中是一个大问题。

即使您使用上面建议的方法插入“转移”记录并使用 map/reduce 视图来计算最终帐户余额,您也无法确保不会透支源帐户,因为存在在检查源账户余额和插入交易之间仍然是一个竞争条件,在检查余额后可以同时添加两个交易。

所以......这是工作的错误工具。 CouchDB 可能擅长很多事情,但这确实是它做不到的。

编辑:可能值得注意的是,现实世界中的实际银行使用最终一致性。如果您透支银行帐户的时间足够长,您将获得透支费。如果你做得很好,你甚至可以几乎同时从两台不同的 ATM 取款并透支你的账户,因为检查余额、发放资金和记录交易存在竞争条件。当您将支票存入您的帐户时,他们会增加余额,但实际上会将这些资金保留一段时间“以防万一”来源帐户确实没有足够的钱。

【讨论】:

  • 这显然是错误的:gist.github.com/wolever/1940301d4f7f530c0791 — 它只是使用了不同(尽管要复杂得多)的事务模型。虽然“银行转账”类型的应用程序确实需要原子操作(沙发有:带有版本检查的文档更新),但不需要锁定。
【解决方案3】:

提供一个具体的例子(因为在线上令人惊讶地缺乏正确的例子):这里是如何在 CouchDB 中实现“atomic bank balance transfer”(大部分复制自我关于同一主题的博客文章:http://blog.codekills.net/2014/03/13/atomic-bank-balance-transfer-with-couchdb/

首先,简要回顾一下这个问题:银行系统如何能够允许 要在账户之间转移的钱被设计成没有种族 可能留下无效或无意义的余额的条件?

这个问题有几个部分:

首先:事务日志。而不是将帐户的余额存储在一个单一的 记录或文件 — {"account": "Dave", "balance": 100} — 帐户的 余额是通过汇总该帐户的所有贷方和借方来计算的。 这些贷方和借方存储在交易日志中,可能看起来 像这样:

{"from": "Dave", "to": "Alex", "amount": 50}
{"from": "Alex", "to": "Jane", "amount": 25}

计算余额的 CouchDB map-reduce 函数看起来 像这样:

POST /transactions/balances
{
    "map": function(txn) {
        emit(txn.from, txn.amount * -1);
        emit(txn.to, txn.amount);
    },
    "reduce": function(keys, values) {
        return sum(values);
    }
}

为完整起见,以下是余额列表:

GET /transactions/balances
{
    "rows": [
        {
            "key" : "Alex",
            "value" : 25
        },
        {
            "key" : "Dave",
            "value" : -50
        },
        {
            "key" : "Jane",
            "value" : 25
        }
    ],
    ...
}

但这留下了一个明显的问题:如何处理错误?如果发生什么 有人试图进行大于余额的转账?

使用 CouchDB(和类似的数据库)这种业务逻辑和错误 处理必须在应用程序级别实现。天真地,这样的功能 可能看起来像这样:

def transfer(from_acct, to_acct, amount):
    txn_id = db.post("transactions", {"from": from_acct, "to": to_acct, "amount": amount})
    if db.get("transactions/balances") < 0:
        db.delete("transactions/" + txn_id)
        raise InsufficientFunds()

但请注意,如果应用程序在插入事务之间崩溃 并检查更新后的余额,数据库将处于不一致的状态 state:发件人可能会留下负余额,而收件人 以前不存在的钱:

// Initial balances: Alex: 25, Jane: 25
db.post("transactions", {"from": "Alex", "To": "Jane", "amount": 50}
// Current balances: Alex: -25, Jane: 75

如何解决这个问题?

为了确保系统永远不会处于不一致状态,两个部分 每笔交易都需要添加信息:

  1. 创建事务的时间(以确保有strict total ordering 个事务),以及

  2. 状态——交易是否成功。

还需要有两个视图——一个返回一个可用的帐户 余额(即所有“成功”交易的总和),另一个 返回最旧的“待处理”交易:

POST /transactions/balance-available
{
    "map": function(txn) {
        if (txn.status == "successful") {
            emit(txn.from, txn.amount * -1);
            emit(txn.to, txn.amount);
        }
    },
    "reduce": function(keys, values) {
        return sum(values);
    }
}

POST /transactions/oldest-pending
{
    "map": function(txn) {
        if (txn.status == "pending") {
            emit(txn._id, txn);
        }
    },
    "reduce": function(keys, values) {
        var oldest = values[0];
        values.forEach(function(txn) {
            if (txn.timestamp < oldest) {
                oldest = txn;
            }
        });
        return oldest;
    }

}

转帐列表现在可能如下所示:

{"from": "Alex", "to": "Dave", "amount": 100, "timestamp": 50, "status": "successful"}
{"from": "Dave", "to": "Jane", "amount": 200, "timestamp": 60, "status": "pending"}

接下来,应用程序需要有一个可以解析的函数 通过检查每个待处理的交易以验证它是 有效,然后将其状态从“待定”更新为“成功”或 “拒绝”:

def resolve_transactions(target_timestamp):
    """ Resolves all transactions up to and including the transaction
        with timestamp `target_timestamp`. """
    while True:
        # Get the oldest transaction which is still pending
        txn = db.get("transactions/oldest-pending")
        if txn.timestamp > target_timestamp:
            # Stop once all of the transactions up until the one we're
            # interested in have been resolved.
            break

        # Then check to see if that transaction is valid
        if db.get("transactions/available-balance", id=txn.from) >= txn.amount:
            status = "successful"
        else:
            status = "rejected"

        # Then update the status of that transaction. Note that CouchDB
        # will check the "_rev" field, only performing the update if the
        # transaction hasn't already been updated.
        txn.status = status
        couch.put(txn)

最后,正确执行传输的应用代码:

def transfer(from_acct, to_acct, amount):
    timestamp = time.time()
    txn = db.post("transactions", {
        "from": from_acct,
        "to": to_acct,
        "amount": amount,
        "status": "pending",
        "timestamp": timestamp,
    })
    resolve_transactions(timestamp)
    txn = couch.get("transactions/" + txn._id)
    if txn_status == "rejected":
        raise InsufficientFunds()

几点说明:

  • 为简洁起见,此特定实现假定一定数量的 CouchDB 的 map-reduce 中的原子性。更新代码使其不依赖 该假设留给读者作为练习。

  • 没有考虑主/主复制或 CouchDB 的文档同步 考虑。主/主复制和同步使这个问题 困难得多。

  • 在实际系统中,使用time() 可能会导致冲突,因此使用 熵多一点的东西可能是个好主意;可能是"%s-%s" %(time(), uuid()),或者在排序中使用文档的_id。 包括时间不是绝对必要的,但它有助于保持逻辑 如果多个请求几乎同时进入。

【讨论】:

  • 这个解决方案假设对于为主-主复制设计的系统来说太不完整了。此外,即使只考虑一个节点,它也没有显示在节点崩溃后必须做什么才能恢复:重启后所有待处理的事务如何处理?另一个问题是需要对所有交易进行串行处理,即使它们不涉及相同的帐户。这似乎并不高效。此外,似乎所有事务都需要永久保留在系统中,从而使事务日志增长过多。如何处理?
  • 我的问题的答案要么不可能,要么过于复杂,无法证明使用 CouchDB 处理多个文档的事务操作是合理的。
【解决方案4】:

BerkeleyDB 和 LMDB 都是支持 ACID 事务的键值对存储。在 BDB 中,txns 是可选的,而 LMDB 仅以事务方式运行。

【讨论】:

    【解决方案5】:

    反对它们的一个典型论点是它们通常不允许跨多行或多表的原子事务。我想知道是否有一种通用方法可以解决这个问题。

    许多现代数据存储不支持开箱即用的原子多键更新(事务),但它们中的大多数都提供了允许您构建 ACID 客户端事务的原语。

    如果数据存储支持每个键的线性化以及比较和交换或测试和设置操作,那么实现可序列化事务就足够了。例如,Google's PercolatorCockroachDB 数据库中使用了这种方法。

    在我的博客中,我创建了step-by-step visualization of serializable cross shard client-side transactions,描述了主要用例并提供了算法变体的链接。我希望它能帮助您了解如何为您的数据存储实现它们。

    支持每个键线性化和 CAS 的数据存储包括:

    • 具有轻量级事务的 Cassandra
    • 具有一致存储桶的 Riak
    • 重新思考数据库
    • 动物园管理员
    • Etdc
    • HBase
    • DynamoDB
    • MongoDB

    顺便说一句,如果您对 Read Committed 隔离级别没问题,那么看看 Peter Bailis 的 RAMP transactions 是有意义的。它们也可以针对同一组数据存储实现。

    【讨论】:

      猜你喜欢
      • 2011-07-04
      • 1970-01-01
      • 1970-01-01
      • 2017-04-09
      • 2018-12-09
      • 1970-01-01
      • 1970-01-01
      • 2019-02-21
      • 2016-04-16
      相关资源
      最近更新 更多