No outro artigo comentamos sobre a nova versão beta fechada do Deno KV e também falamos sobre o conceito de chave e valor. Mas, infelizmente, como o artigo já estava meio grande, algumas coisas ficaram de fora, uma dessas coisas foi a incrível capacidade de atomicidade do KV.
Mas o que é ser atômico? E o que isso significa no contexto de um banco de dados desse porte?
Atomicidade
Atomicidade é a letra A do acrônimo ACID que você provavelmente já ouviu, cada uma das letras do ACID tem um significado profundo para qualquer banco de dados, especialmente se ele suportar transações:
- Atomicidade: Que é a mais importante pra gente aqui agora. Esse nada mais é do que o conceito de transações. Ou seja, um conjunto de operações que são executados de uma única vez como se fossem um único átomo, ou tudo acontece, ou nada acontece.
- Consistência: Garantia de que as transações no banco só vão modificar as tabelas de forma pré-definida. Além disso, essa propriedade também garante que qualquer tipo de corrupção de dados ou perda em um determinado lugar não vai afetar outras tabelas.
- Isolamento: Essencialmente trata o problema de corrida, quando múltiplos usuários estão realizando leituras e escritas no banco de dados, o isolamento das transações garante que essas transações concorrentes não interfiram umas com as outras.
- Durabilidade: É a principal propriedade de um banco de dados. Ela garante que qualquer mudança em seus dados através de qualquer transação de sucesso vão ser persistidas mesmo no evento de uma falha de sistema.
Além de operações não atômicas, o KV também suporta transações atômicas, onde ou tudo é executado ou nada é executado. Esse conceito é baseado em mutations que são um conjunto de ações aplicados a um registro.
Mais uma vez o KV usa as versionstamps
para poder saber o que foi modificado ou não, a transação só será enviada com sucesso se as versionstamps
atuais das chaves forem as mesmas das que foram passadas na mutação, dessa forma é possível garantir que estamos modificando a última versão dos dados.
Nestas operações estão inclusas todas as anteriores com algumas diferenças:
check
: equivalente aoget
, porém ao invés de obter uma chave, vai testar uma chave já obtida contra o versionstamp que está no banco de dadossum
: um tipo de operaçãomutate
, mas sem atalho diretomin
: outromutate
, mas que tem um atalho diretomax
: outromutate
, também um atalho diretocommit
: finaliza a transação e envia os valores ao bancodelete
: igual ao anterior
Para esta explicação é melhor usar um exemplo do que passar método por método, já que a maioria das funcionalidades são conhecidas. O próprio exemplo do Deno é muito bom porque é uma das principais funcionalidades quando estamos trabalhando com transações: transferência de dinheiro.
Quando estamos transferindo fundos de uma conta para a outra, primeiro temos que garantir que a primeira conta tem os fundos, se fizermos de forma assíncrona, é possível que, enquanto estamos tentando transferir o dinheiro de uma conta para a outra, uma transação aconteça no meio e não tenhamos mais os fundos necessários, por isso precisamos executar todas as operações (tirar de uma conta e colocar em outra) de uma vez só, ou não executar nenhuma.
const senderKey = ['account', 'alice']
const receiverKey = ['account', 'bob']
cosnt amount = 100
// tentamos a transação até conseguir
let res = { ok: false }
while (!res.ok) {
const [senderResponse, receiverResponse] = await kv.getMany([senderKey, receiverKey])
if (!senderResponse || !receiverResponse) break
const senderBalance = senderRes.value
const receiverBalance = receiverRes.value
if (senderBalance < amount) {
throw new Error('Saldo insuficiente')
}
const newSenderBalance = senderBalance - amount
const newReceiverBalance = receiverBalance + amount
// salvamos no banco de dados
}
Até aqui estamos fazendo tudo em memória. Para salvarmos no banco precisamos primeiro checar os dois saldos, podemos fazer isso com o comando check
:
const senderKey = ['account', 'alice']
const receiverKey = ['account', 'bob']
const amount = 100
// tentamos a transação até conseguir
let res = { ok: false }
while (!res.ok) {
const [senderResponse, receiverResponse] = await kv.getMany([senderKey, receiverKey])
if (!senderResponse || !receiverResponse) break
const senderBalance = senderRes.value
const receiverBalance = receiverRes.value
if (senderBalance < amount) {
throw new Error('Saldo insuficiente')
}
const newSenderBalance = senderBalance - amount
const newReceiverBalance = receiverBalance + amount
// salvamos no banco de dados
res = await kv.atomic()
.check(senderResponse)
.check(receiverResponse)
}
Aqui é importante notar duas coisas:
- Estamos usando o método
atomic()
, que é o namespace que vai ter todas as propriedades atômicas do KV - Estamos usando o
check
e passando como parâmetro não um valor, mas a resposta inteira do comandogetMany
, porque precisamos passar tanto o valor quanto aversionstamp
daquela chave
O que o check
vai fazer é realizer um get
no KV e verificar se os dois registros são idênticos, se falhar, então a transação será abortada. Agora podemos atualizar os valores de cada pessoa:
const senderKey = ['account', 'alice']
const receiverKey = ['account', 'bob']
const amount = 100
// tentamos a transação até conseguir
let res = { ok: false }
while (!res.ok) {
const [senderResponse, receiverResponse] = await kv.getMany([senderKey, receiverKey])
if (!senderResponse || !receiverResponse) break
const senderBalance = senderRes.value
const receiverBalance = receiverRes.value
if (senderBalance < amount) {
throw new Error('Saldo insuficiente')
}
const newSenderBalance = senderBalance - amount
const newReceiverBalance = receiverBalance + amount
// salvamos no banco de dados
res = await kv.atomic()
.check(senderResponse)
.check(receiverResponse)
.set(senderKey, newSenderBalance)
.set(receiverKey, newReceiverBalance)
.commit()
}
Toda transação deve ser finalizada com um commit()
para que possamos executar a fila de operações que foram feitas.
Agora que a gente entendeu o conceito, vamos ver as outras operações.
kv.atomic().mutate()
- Sum, Min e Max
Além das operações normais, temos um outro método chamado mutate
dentro de atomic()
, este método aceita um objeto de configuração que pode ter três chaves:
type
: O tipo da mutação, que pode ser, até o momento,sum
,min
oumax
key
: A chave a ser modificadavalue
: O novo valor, precisa ser um objeto do tipoDeno.KvU64
que é criado a partir de umBigInt
comnew Deno.KvU64(100n)
por exemplo
Vamos falar sobre as mutações no geral, vou dar o primeiro exemplo com o sum
, mas não há muito a necessidade de explicar as demais com exemplos porque elas seguem a mesma ideia:
Sum
O sum
vai atomicamente adicionar um valor a uma chave. Se o valor não existir, ele vai ser criado com o valor que seria adicionado, por exemplo, se adicionarmos 10 em uma chave não existente, o resultado vai ser a chave que queremos adicionar, com o valor 10. Se a chave existir, o valor vai ser adicionado através de uma soma.
Operações de mutation só podem ser feitas em tipos de dados BigInt que, no Deno KV, são representadas pelo tipo Deno.KvU64
, que significa Deno KV Unsigned 64-bit Integer, esse tipo não pode ser armazenado em nenhuma estrutura, ele precisa ser um top-level value.
A estrutura básica de uma soma é a seguinte:
await kv.atomic()
.mutate({
type: 'sum',
key: ['accounts', 'alice'],
value: new Deno.KvU64(80n),
})
.commit()
Isso significa que podemos substituir nosso código acima por algo como:
const senderKey = ['account', 'alice']
const receiverKey = ['account', 'bob']
const amount = 100
// tentamos a transação até conseguir
let res = { ok: false }
while (!res.ok) {
const [senderResponse, receiverResponse] = await kv.getMany([senderKey, receiverKey])
if (!senderResponse || !receiverResponse) break
const senderBalance = senderRes.value
const receiverBalance = receiverRes.value
if (senderBalance < amount) {
throw new Error('Saldo insuficiente')
}
// salvamos no banco de dados
res = await kv.atomic()
.check(senderResponse)
.check(receiverResponse)
.mutate({
type: 'sum',
key: senderKey,
value: new Deno.KvU64(-BigInt(amount)),
})
.mutate({
type: 'sum',
key: receiverKey,
value: new Deno.KvU64(BigInt(amount)),
})
.commit()
}
Min e Max
Da mesma forma que o sum
, o min
e max
setados como type
vão fazer com que a chave obtenha o menor e maior valor, respectivamente, comparado entre o valor atual da chave e o valor que você está passando.
Por exemplo, se tivermos uma chave ['accounts', 'alice']
cujo valor é 100
, e passarmos uma mutation do tipo min
com o valor de 50
, o novo valor vai ser Math.min(100, 50)
que é 50
, porém se o valor original for 30
, a chave não será modificada. O mesmo vale para o max
.
Da mesma forma que o sum
, se a chave não existir ela será criada com o valor passado, ou seja, não será assumido que o valor é 0
inicialmente para nenhum dos casos.
await kv.atomic()
.mutate({
type: 'min',
key: ['accounts', 'alice'],
value: new Deno.KvU64(100n),
})
.commit()
Conclusão
Com as operações atômicas, podemos realizar transações mais seguras que são garantidas de terem o resultado esperado.
Nos próximos artigos vamos construir algum projeto usando o Deno Deploy e o Deno KV!