Lock Distribuído
Um lock distribuído impede que processos concorrentes executem uma seção crítica ao mesmo tempo. Locks baseados em banco de dados trocam throughput por simplicidade — não é necessário Redis, e o mesmo DB que armazena seus dados armazena seus locks.
Conceitos fundamentais
- Recurso: o nome da coisa sendo bloqueada (ex.:
job:42,report:monthly-2026-05) - Proprietário: um token que identifica o detentor do lock — apenas o proprietário pode liberar ou renovar
- Expiração (TTL): locks expiram automaticamente para que um proprietário travado não possa manter um lock para sempre
- Reivindicação de lock obsoleto: um lock expirado pode ser tomado por um novo proprietário
Schema
CREATE TABLE distributed_locks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource TEXT NOT NULL UNIQUE,
owner TEXT NOT NULL,
expires_at TEXT NOT NULL,
acquired_at TEXT NOT NULL
);A constraint UNIQUE em resource garante que exista apenas uma linha por recurso. INSERTs concorrentes são serializados no nível do DB.
Lógica de aquisição
public function acquire(string $resource, string $owner, string $expiresAt, string $now): ?LockRecord
{
$existing = $this->findByResource($resource);
if ($existing === null) {
// Sem lock — INSERT (pode falhar em corrida; chamador recebe null e tenta novamente)
$this->executor->execute(
'INSERT INTO distributed_locks (resource, owner, expires_at, acquired_at) VALUES (?, ?, ?, ?)',
[$resource, $owner, $expiresAt, $now],
);
return $this->findByResource($resource);
}
if ($existing->isExpired($now) || $existing->owner === $owner) {
// Expirado (obsoleto) ou mesmo proprietário re-adquirindo — UPDATE para reivindicar
$this->executor->execute(
'UPDATE distributed_locks SET owner = ?, expires_at = ?, acquired_at = ? WHERE resource = ?',
[$owner, $expiresAt, $now, $resource],
);
return $this->findByResource($resource);
}
// Mantido por outro proprietário e ainda válido — não pode adquirir
return null;
}Convenções do valor de retorno:
- Retorna um
LockRecordem caso de sucesso (acquired: truena resposta da API) - Retorna
nullquando o lock é mantido por outro proprietário (acquired: false)
Liberação com verificação de proprietário
Apenas o proprietário pode liberar. Retornar 403 (não 404) quando o proprietário não corresponde informa ao chamador que o lock existe mas ele não o detém:
return match ($result) {
ReleaseResult::Released => $this->json->create([], 204),
ReleaseResult::NotFound => $this->problems->create($request, 'not-found', 'Lock not found.', 404, ''),
ReleaseResult::Forbidden => $this->problems->create($request, 'forbidden', 'Owner mismatch.', 403, ''),
};Renovação de TTL
Tarefas de longa duração precisam estender seu lock antes que ele expire. Apenas o proprietário atual pode renovar — uma renovação por proprietário errado retorna 409 (não 403) porque sinaliza um conflito de estado, não uma negação de permissão:
if ($existing->isExpired($now)) {
return null; // → 409: não é possível renovar um lock expirado (outro pode agora detê-lo)
}
if ($existing->owner !== $owner) {
return null; // → 409: proprietário errado
}
// Estender expires_atDetecção de lock obsoleto
LockRecord::isExpired() compara o horário atual com expires_at:
public function isExpired(string $now): bool
{
return $now >= $this->expiresAt;
}Isso significa que GET /locks/{resource} retorna 404 para locks expirados (tratando expirado como inexistente), e POST /locks/{resource} permite que um novo proprietário reivindique um lock expirado.
Decisões de design
Por que não Redis SETNX? Redis fornece SETNX atômico com TTL em um único comando e é o padrão de produção para locking de alto throughput. O locking baseado em DB é mais simples de implantar (sem serviço adicional), consistente com o restante dos dados transacionais e suficiente para cenários de contenção baixa a média (jobs em background, geração de relatórios, processamento em lote).
Por que não DELETE+INSERT no re-acquire? UPDATE preserva o ID da linha e é atômico. DELETE+INSERT criaria uma breve janela onde nenhuma linha de lock existe, permitindo que um processo concorrente faça INSERT e roube o lock.
Por que separar acquired_at de expires_at?acquired_at é o timestamp de quando a propriedade foi estabelecida pela última vez (útil para auditoria). expires_at muda na renovação. Mantê-los separados evita ambiguidade.
Não-bloqueante por design O endpoint de lock retorna imediatamente com acquired: false em vez de bloquear até que o lock esteja disponível. Os chamadores implementam sua própria estratégia de retry (backoff exponencial, fila de dead letter, etc.) com base em seus requisitos de timeout.