В качестве конкретного примера (потому что есть удивительное отсутствие правильных примеров онлайн): вот как реализовать «atomic bank balance transfer» в CouchDB (в основном скопированы из моего сообщения в блоге на эту же тему: 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 карта-свертка функции для расчета баланса может выглядеть что-то вроде этого:
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
}
],
...
}
Но этот отпуск s очевидный вопрос: как обрабатываются ошибки? Что произойдет, если кто-то пытается сделать перевод больше, чем их баланс?
С 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()
Но обратите внимание, что если происходит сбой приложения между вставкой транзакции и проверять обновленные балансы база данных будет оставаться в противоречивом состоянии: отправитель может быть влево с отрицательным балансом, и получателем с денег, которые ранее не существовали:
// Initial balances: Alex: 25, Jane: 25
db.post("transactions", {"from": "Alex", "To": "Jane", "amount": 50}
// Current balances: Alex: -25, Jane: 75
Как это можно исправить?
Для того, чтобы убедиться, что система никогда не находится в неустойчивом состоянии, две части информации должна быть добавлена к каждой сделке:
Время создания транзакции (чтобы убедиться, что существует strict total ordering транзакций) и
Статус: была ли транзакция успешной.
Там также должны быть два вида - один, который возвращает доступный баланс между аккаунтом, (т.е. сумма всех «успешных» операций), а другой, который возвращает самый старый «отложенный» сделки:
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. Обновляя код, чтобы он не полагался на , это предположение остается в качестве упражнения для читателя.
Репликация оригиналов/мастеров или синхронизация документа CouchDB не были приняты во внимание . Репликация мастера и мастера и синхронизация делают эту проблему значительно сложнее.
В реальной системе использование time()
может привести к столкновениям, поэтому использование может быть хорошей идеей; возможно "%s-%s" %(time(), uuid())
, или используя документ _id
в заказе. Включение времени не является строго необходимым, но оно помогает поддерживать логический , если несколько запросов поступают примерно в одно и то же время.
CouchDB не является ключом, это хранилище документов. – OrangeDog