键值存储中的原子事务

时间:2009-07-07 15:44:47

标签: couchdb transactions cassandra key-value

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

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

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

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

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

如果基础数据自上次抓取以来发生了变化,那么一些(全部?)键值存储将“回滚”一个动作。可能这可能用于模拟原子事务,然后,您可以指示特定字段被锁定。这种方法存在一些明显的问题。

还有其他想法吗?完全有可能我的方法完全不正确,我还没有把我的大脑包围在新的思维方式中。

5 个答案:

答案 0 :(得分:11)

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

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

答案 1 :(得分:4)

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

要完成银行转帐,您必须做以下几件事:

  1. 验证交易,确保源帐户中有足够的资金,这两个帐户都是开放的,没有锁定的,并且信誉良好,等等。
  2. 减少来源帐户的余额
  3. 增加目标帐户的余额
  4. 如果在这些步骤之间对帐户的余额或状态进行了更改,则交易在提交后可能会变为无效,这是此类系统中的一个大问题。

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

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

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

答案 2 :(得分:3)

提供一个具体的例子(因为在网上有令人惊讶的缺乏正确的例子):这里是如何在CouchDB中实现“atomic bank balance transfer”(主要是从我在同一主题的博客文章中复制的:{{3 }})

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

这个问题有几个部分:

首先:事务日志。而不是将帐户的余额存储在一个单一的 记录或文档 - {"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. 创建交易的时间(以确保有http://blog.codekills.net/2014/03/13/atomic-bank-balance-transfer-with-couchdb/个交易)和

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

  3. 还需要有两个视图 - 一个返回帐户可用的视图 平衡(即所有“成功”交易的总和),以及另一个 返回最早的“待处理”交易:

    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。 包括时间不是绝对必要的,但它有助于保持合理性 如果多个请求几乎同时进入。

答案 3 :(得分:1)

BerkeleyDB和LMDB都是支持ACID交易的键值存储。在BDB中,txns是可选的,而LMDB只在事务上运行。

答案 4 :(得分:1)

  

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

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

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

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

支持每个密钥线性化和CAS的数据存储是:

  • Cassandra与轻量级交易
  • 拥有一致水桶的Riak
  • RethinkDB
  • 动物园管理员
  • Etdc
  • HBase的
  • DynamoDB
  • MongoDB的

顺便说一句,如果你对Read Committed隔离级别没问题,那么看看Peter Bailis的RAMP transactions就行了。它们也可以用于同一组数据存储。