CommunityRedacción y edicióngithub.com

Concurrencia en PostgreSQL

Escritura concurrente en PostgreSQL sin actualizaciones perdidas ni write skew: condición en UPDATE, SELECT FOR NO KEY UPDATE vs FOR UPDATE, restricciones de exclusión contra intersección de intervalos, serializable y reintento ante 40001/40P01/55P03, cola de tareas con SKIP LOCKED, lock_timeout y NOWAIT, bloqueos de asesoramiento, análisis de bloqueos. Ejemplos en Go (pgx y go-transaction-manager), reglas sobre PostgreSQL mismo.

¿Qué es Concurrencia en PostgreSQL?

Este skill cubre todas las técnicas esenciales para manejar la escritura concurrente en PostgreSQL de manera segura y eficiente, evitando problemas como actualizaciones perdidas, write skew y puntos muertos. Explica cómo elegir el mecanismo adecuado según el tipo de invariante: desde simples condiciones en UPDATE para proteger una fila, pasando por SELECT FOR NO KEY UPDATE para leer y luego escribir la misma fila, hasta restricciones de exclusión con GiST para evitar la superposición de intervalos sin necesidad de serializable. También cubre el uso de serializable con reintentos ante error 40001 para invariantes complejos, colas de tareas con FOR UPDATE SKIP LOCKED para distribuir trabajo entre workers, y bloqueos de asesoramiento (advisory locks) para singletons como migradores. Incluye ejemplos prácticos en Go con pgx y go-transaction-manager, y recomienda siempre que sea posible implementar las restricciones en la base de datos en lugar de en el código de la aplicación. Soporta PostgreSQL 12+ y cualquier cliente que use SQL estándar.

Compatible con✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/scary-production/claude-plugins/tree/HEAD/plugins/go-postgres/skills/postgres-concurrency

Installed? Explore more Redacción y edición skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Escritura concurrente en PostgreSQL

Lo principal. No hagas «leer en código — verificar — escribir»: en read committed esto pierde los cambios de otros. Elige el medio según el tipo de invariante:

InvarianteMedio
Cambiar una fila si la condición sigue siendo verdaderaun solo UPDATE ... WHERE <condición> + verificar número de filas
Leer, decidir en código, escribir la misma filaSELECT ... FOR NO KEY UPDATE y escritura en una transacción
Sin duplicadoíndice único + INSERT ... ON CONFLICT
Los intervalos no se superponenEXCLUDE USING gist (btree_gist)
Regla sobre un conjunto de filas que no se puede expresar como restricciónserializable en todos los participantes + reintento
Cola de tareasFOR UPDATE SKIP LOCKED
Un solo manejador para todospg_advisory_xact_lock

La restricción en la base de datos es más confiable que en el código: mantiene el invariante en cualquier nivel de aislamiento y con cualquier cliente. El código Go de transacciones y reintentos está en el skill pgx-txmanager. La verificación de afirmaciones está en CHECKS.md.

Niveles de aislamiento

  • Read committed (por defecto): cada comando ve una instantánea fresca. UPDATE en una fila ocupada espera la transacción ajena, luego relee la fila y vuelve a verificar WHERE.
  • Repeatable read y serializable no releen: en caso de conflicto, error 40001 («could not serialize access»); serializable lo da con más frecuencia. Si subes el nivel, escribe inmediatamente el reintento de toda la transacción, de lo contrario el usuario recibirá un error. El 40001 puede llegar incluso en COMMIT.
  • La instantánea de repeatable read se construye con el primer comando después de BEGIN, no con el propio BEGIN. El momento es importante: haz la lectura como primera consulta.
  • Serializable garantiza el resultado solo entre transacciones serializable. Si proteges un invariante con él, todas las transacciones que lo tocan deben ir en serializable.
  • Dentro de serializable, mira EXPLAIN. Seq Scan toma un bloqueo de predicado en toda la tabla y aumenta la proporción de 40001; la lectura por índice bloquea solo las filas leídas y las páginas del índice. Crea los índices necesarios. En una tabla pequeña, Seq Scan es normal.

Cambiar si la condición es verdadera: condición en UPDATE

Pasa la acción a la base de datos, no el número calculado: SET sent = sent + 1, SET left = left - $1 WHERE left >= $1, WHERE id = $1 AND status = 'free'. Cero filas afectadas (RowsAffected() == 0 o pgx.ErrNoRows con RETURNING) significa que la condición no se cumple o la fila no existe. Esto es un error de dominio, no un éxito.

tag, err := q.Exec(ctx,
	`UPDATE seats SET status = 'booked', customer_id = $2
	 WHERE id = $1 AND status = 'free'`,
	seatID, customerID,
)
if err != nil {
	return fmt.Errorf("book seat %d: %w", seatID, err)
}
if tag.RowsAffected() == 0 { // el asiento ya está ocupado o no existe
	return fmt.Errorf("book seat %d: %w", seatID, ErrConflict)
}

En repeatable read y serializable, el mismo conflicto termina en 40001 y requiere reintento.

Leer y escribir la misma fila

SELECT ... FOR NO KEY UPDATE (el modo está en la sección «Modo de bloqueo») y escritura en una misma transacción (trManager.Do). En autocommit, el bloqueo se libera inmediatamente después del SELECT. Una fila que aún no existe no está protegida por el bloqueo: la inserción de un duplicado solo la detiene un índice único o una restricción.

Decisión sobre un conjunto de filas (write skew)

«El ejecutor está libre», «la reserva cabe en el intervalo», «queda un guardia en el turno»: dos transacciones leen la misma instantánea, cada una escribe su fila, y ambas confirman. En read committed, esto produce dos reservas superpuestas. Repeatable read no salva: solo detecta escritura en la misma fila.

  • Decisión sobre filas existentes: bloquea (FOR NO KEY UPDATE / FOR UPDATE) exactamente las filas sobre las que decides.
  • Decisión sobre la ausencia de una fila («no hay reserva superpuesta»): no hay nada que bloquear. Se necesita una restricción:
    • duplicado por valor: índice único;

    • superposición de intervalos: restricción de exclusión (exclusion constraint). En read committed, la segunda transacción espera a la primera y obtiene 23P01, sin serializable y sin reintentos:

      CREATE EXTENSION IF NOT EXISTS btree_gist; -- trusted: basta con permiso CREATE en la base de datos
      ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
          EXCLUDE USING gist (resource_id WITH =, during WITH &&); -- during tstzrange
      

      23P01 (exclusion_violation) se convierte en un error de dominio de conflicto.

  • La regla no se puede expresar con una restricción: serializable para todos los participantes y reintento ante 40001.

Modo de bloqueo de fila

Bloqueas una fila para leerla y actualizarla, y no cambias columnas con índice único apto para clave externa (PK, claves únicas; los índices parciales y los índices por expresión no cuentan) — usa FOR NO KEY UPDATE. FOR UPDATE déjalo para DELETE y cambio de dichas columnas. Por qué: la verificación de clave externa al insertar una fila hija toma FOR KEY SHARE en el padre. Este entra en conflicto con FOR UPDATE pero no con FOR NO KEY UPDATE. Un FOR UPDATE innecesario en la fila padre (cliente, recurso, pedido) detiene la inserción de filas que la referencian.

Cola de tareas

Varios workers toman una tarea con un solo operador:

UPDATE tasks
SET status = 'running', taken_at = now()
WHERE id = (
	SELECT id FROM tasks
	WHERE status = 'ready'
	ORDER BY id
	LIMIT 1
	FOR UPDATE SKIP LOCKED
)
RETURNING id
  • SKIP LOCKED omite las filas ocupadas, y los workers no esperan entre sí. El par «SELECT ... FOR UPDATE SKIP LOCKED, luego UPDATE» en una misma transacción también es correcto. No se puede hacer un SELECT sin bloqueo y un UPDATE separado: dos workers tomarían la misma tarea.
  • ORDER BY en la subconsulta es para un orden predecible.
  • Un worker puede fallar con la tarea en sus manos. Práctica: plazo de arrendamiento según taken_at — una tarea en running por más del plazo se considera nuevamente lista.
  • SKIP LOCKED da una visión inconsistente de los datos — solo para colas.

No quedarse colgado en un bloqueo ajeno

Por defecto, una consulta espera un bloqueo indefinidamente, y el usuario ve la aplicación congelada.

  • SET LOCAL lock_timeout dentro de la transacción, no como parámetro de sesión: la conexión del pool luego irá a otras consultas. El timeout actúa en cada espera de bloqueo, no en toda la transacción.
  • UPDATE y DELETE no tienen NOWAIT. Si necesitas un error instantáneo, primero haz SELECT ... FOR UPDATE NOWAIT sobre esas filas en la misma transacción.
  • NOWAIT y SKIP LOCKED afectan solo a filas. Un bloqueo de tabla lo espera la consulta como siempre, por ejemplo tras ALTER TABLE. Por eso lock_timeout es necesario incluso con ellos.
  • El error en estos casos es 55P03 (lock_not_available). Reintenta toda la transacción.
return s.trManager.Do(ctx, func(ctx context.Context) error {
	q := s.getter.DefaultTrOrDB(ctx, s.pool)
	// constante sin entrada; ajusta el valor según la operación
	if _, err := q.Exec(ctx, `SET LOCAL lock_timeout = '1s'`); err != nil {
		return err
	}
	return s.repo.MoveBooking(ctx, from, to)
})

La cola de espera no es justa. Nuevos FOR SHARE / FOR KEY SHARE pueden adelantarse a un UPDATE que espera en una fila caliente, y un flujo de dichos bloqueos lo mantiene esperando indefinidamente; el orden entre los que esperan no está garantizado. Para filas calientes (contador compartido, stock de un producto popular), pon lock_timeout y reintenta la transacción.

Análisis de un bloqueo. Los bloqueos de fila no se ven en pg_locks: el proceso en espera se ve allí como esperando el transactionid del poseedor. Quién está bloqueando lo muestra pg_blocking_pids(pid) (no lo llames en un bucle frecuente). Los bloqueos de fila de una tabla los muestra la extensión pgrowlocks de contrib (la instala el superusuario).

Bloqueos de asesoramiento (Advisory locks)

«Solo un manejador» (worker único de outbox, migrador) — pg_advisory_xact_lock dentro de una transacción: el candado se libera automáticamente al hacer commit o rollback.

const advisoryOutboxRelay int64 = 1 // clave fija del subsistema

err := s.trManager.Do(ctx, func(ctx context.Context) error {
	q := s.getter.DefaultTrOrDB(ctx, s.pool)
	if _, err := q.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, advisoryOutboxRelay); err != nil {
		return err
	}
	return s.relayBatch(ctx) // un lote; el candado se libera con la transacción
})
  • pg_advisory_lock a nivel de sesión — solo en una conexión dedicada tomada con pool.Acquire; libéralo en la misma conexión tantas veces como lo hayas adquirido. El candado de sesión no se libera ni con COMMIT ni con rollback, y la misma sesión puede volver a tomarlo. En un pool, la conexión regresa con el candado, el unlock va a otra conexión, y el candado se «fuga».
  • No mantengas una transacción abierta por un candado en un worker infinito: toma un candado xact por cada lote de trabajo. Para un líder de larga duración, usa un candado de sesión en una conexión dedicada.
  • La clave es una constante numérica fija por subsistema o un par (int, int). No uses hashtext: la función no está documentada, el hash de 32 bits produce colisiones.

Funciones en consultas

Una función en plpgsql o SQL que lee tablas y se llama en una consulta junto con la lectura de tablas, márcala como STABLE. VOLATILE toma una nueva instantánea para cada consulta interna, y en read committed los datos de la tabla y de la función provendrían de momentos diferentes. IMMUTABLE solo para funciones que no leen tablas; VOLATILE para funciones con efectos secundarios.

Códigos de error

CódigoQué esQué hacer
40001 serialization_failureconflicto en repeatable read / serializablereintentar toda la transacción
40P01 deadlock_detectedbloqueo mutuoreintentar toda la transacción; tomar los bloqueos en el mismo orden
55P03 lock_not_availableNOWAIT, lock_timeoutreintentar toda la transacción o devolver «ocupado»
23505 unique_violationduplicadoconflicto de dominio; duplicado esperado — ON CONFLICT
23P01 exclusion_violationsuperposición por restricción de exclusiónconflicto de dominio

Individual skills in this repo

This repo contains 4 individual skills — each has its own dedicated page.

scary-production/claude-plugins

Работа с PostgreSQL в Go через pgxpool (pgx v5) и avito-tech/go-transaction-manager: пул и устойчивый старт, структуры с тегами db и CollectRows, репозиторий через DefaultTrOrDB, транзакции через Do, propagation и savepoint, повтор транзакции при 40001/40P01/55P03, защита от SQL-инъекций, классификация ошибок и ON CONFLICT, batch и CopyFrom. Конкурентная запись и блокировки — скилл postgres-concurrency. PostgreSQL in Go via pgxpool and go-transaction-manager: pool, repositories, transactions, retries, errors, bulk loads.

scary-production/claude-plugins

SQL-миграции PostgreSQL: парные up/down-файлы, современные типы и ограничения (identity, text с CHECK, timestamptz, jsonb), откат каждой операции, добавление значения в enum, индексы на живых таблицах через CREATE INDEX CONCURRENTLY без блокировки записи, что требовать от мигратора, разбор «индекс создан, а план — Seq Scan». PostgreSQL migrations: paired up/down files, modern types, reversible changes, non-blocking indexes, migrator requirements.

scary-production/claude-plugins

Как найти, что тормозит в PostgreSQL, и доказать, что правка помогла. Профиль по pg_stat_statements, сравнение вариантов по числу страниц (BUFFERS), а не по секундам, разбор плана от места, где оценка строк впервые разошлась с фактом. N+1 из Go-цикла сворачиваем в один запрос. Правим условия, FK, статистику и память, а не боремся с планировщиком. Postgres query tuning: profile first, compare by buffers, fix row estimates, remove N+1.

scary-production/claude-plugins

Показывает стили строки состояния на примере или включает один из них (full, compact, bars, minimal) в настройках пользователя или проекта.

Skills relacionados