Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
Los bloqueos son uno de los pilares fundamentales de cualquier programa informático concurrente. Cuando ocurren múltiples operaciones simultáneamente, los programadores recurren a bloqueos para garantizar la exclusión mutua del acceso concurrente a un recurso. Estos mecanismos (y otras primitivas de exclusión mutua) existen en múltiples niveles de la pila tecnológica, desde instrucciones de CPU de bajo nivel hasta APIs de aplicación como sync.Mutex en Go.
Al trabajar con bases de datos relacionales, una necesidad común para los desarrolladores es la capacidad de adquirir bloqueos sobre registros. Imagina una tabla inventory que lista artículos disponibles en un sitio de comercio electrónico. Esta tabla podría tener una columna state con valores available o purchased. Para evitar que dos usuarios crean haber comprado el mismo artículo, la aplicación debe impedir que dos operaciones modifiquen simultáneamente el estado del artículo de disponible a comprado.
¿Cómo garantiza esto la aplicación? No basta con que el servidor verifique si el artículo está available antes de marcarlo como purchased. Imagina que dos usuarios intentan comprar simultáneamente el mismo artículo. Dos solicitudes llegarían casi al mismo tiempo al servidor. Ambas consultarían el estado del artículo en la base de datos y verían que está available. Ante esto, ambos manejadores de solicitudes ejecutarían una consulta UPDATE estableciendo el estado a purchased y el buyer_id al ID del usuario solicitante. Ambas actualizaciones tendrían éxito, pero el estado final del registro sería que el usuario que ejecutó la última consulta UPDATE sería considerado el comprador.
Con los años han evolucionado diversas técnicas que permiten a los desarrolladores crear aplicaciones que ofrecen estas garantías. Algunas involucran mecanismos explícitos de bloqueo proporcionados por las bases de datos, mientras que otras se basan en propiedades ACID más generales para lograr exclusión mutua. En esta publicación exploraremos la implementación de dos de estas técnicas usando Ent.
Bloqueo optimista
El bloqueo optimista (también llamado Control de Concurrencia Optimista) es una técnica que permite lograr comportamiento de bloqueo sin adquirir explícitamente un bloqueo sobre ningún registro.
En términos generales, así funciona el bloqueo optimista:
Cada registro tiene un número de versión numérico que debe ser monótonamente creciente. Normalmente se usan timestamps Unix de la última actualización.
Una transacción lee el registro, anotando su número de versión desde la base de datos.
Se ejecuta una sentencia
UPDATEpara modificar el registro:- La sentencia debe incluir un predicado que requiera que el número de versión no haya cambiado. Ejemplo:
WHERE id=<id> AND version=<previous version>. - La sentencia debe incrementar la versión. Algunas aplicaciones aumentan el valor actual en 1, otras lo establecen al timestamp actual.
- La sentencia debe incluir un predicado que requiera que el número de versión no haya cambiado. Ejemplo:
La base de datos devuelve la cantidad de filas modificadas por la sentencia
UPDATE. Si el número es 0, significa que alguien modificó el registro entre nuestra lectura y la actualización. La transacción se considera fallida, se revierte y puede reintentarse.
El bloqueo optimista se usa comúnmente en entornos de "baja contención" (donde la probabilidad de interferencia entre transacciones es reducida) y donde la lógica de bloqueo puede confiarse a la capa de aplicación. Si existen escritores en la base de datos que no garantizan cumplir esta lógica, la técnica pierde utilidad.
Veamos cómo implementar esta técnica usando Ent.
Comenzamos definiendo nuestro ent.Schema para un User. El usuario tiene un campo booleano online para especificar si está actualmente conectado y un campo int64 para el número de versión actual.
// User holds the schema definition for the User entity.
type User struct {
ent.Schema
}
// Fields of the User.
func (User) Fields() []ent.Field {
return []ent.Field{
field.Bool("online"),
field.Int64("version").
DefaultFunc(func() int64 {
return time.Now().UnixNano()
}).
Comment("Unix time of when the latest update occurred")
}
}
A continuación, implementemos una actualización con bloqueo optimista simple para nuestro campo online:
func optimisticUpdate(tx *ent.Tx, prev *ent.User, online bool) error {
// The next version number for the record must monotonically increase
// using the current timestamp is a common technique to achieve this.
nextVer := time.Now().UnixNano()
// We begin the update operation:
n := tx.User.Update().
// We limit our update to only work on the correct record and version:
Where(user.ID(prev.ID), user.Version(prev.Version)).
// We set the next version:
SetVersion(nextVer).
// We set the value we were passed by the user:
SetOnline(online).
SaveX(context.Background())
// SaveX returns the number of affected records. If this value is
// different from 1 the record must have been changed by another
// process.
if n != 1 {
return fmt.Errorf("update failed: user id=%d updated by another process", prev.ID)
}
return nil
}
Ahora escribamos una prueba para verificar que si dos procesos intentan editar el mismo registro, solo uno tendrá éxito:
func TestOCC(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
ctx := context.Background()
// Create the user for the first time.
orig := client.User.Create().SetOnline(true).SaveX(ctx)
// Read another copy of the same user.
userCopy := client.User.GetX(ctx, orig.ID)
// Open a new transaction:
tx, err := client.Tx(ctx)
if err != nil {
log.Fatalf("failed creating transaction: %v", err)
}
// Try to update the record once. This should succeed.
if err := optimisticUpdate(tx, userCopy, false); err != nil {
tx.Rollback()
log.Fatal("unexpected failure:", err)
}
// Try to update the record a second time. This should fail.
err = optimisticUpdate(tx, orig, false)
if err == nil {
log.Fatal("expected second update to fail")
}
fmt.Println(err)
}
Ejecutando nuestra prueba:
=== RUN TestOCC
update failed: user id=1 updated by another process
--- PASS: Test (0.00s)
¡Genial! Usando bloqueo optimista podemos evitar que dos procesos interfieran entre sí.
Bloqueo Pesimista
Como mencionamos anteriormente, el bloqueo optimista no siempre es apropiado. Para casos de uso donde preferimos delegar la responsabilidad de mantener la integridad del bloqueo a las bases de datos, algunos motores (como MySQL, Postgres y MariaDB, pero no SQLite) ofrecen capacidades de bloqueo pesimista. Estas bases de datos soportan un modificador en las sentencias SELECT llamado SELECT ... FOR UPDATE. La documentación de MySQL explica:
Un SELECT ... FOR UPDATE lee los últimos datos disponibles, estableciendo bloqueos exclusivos en cada fila que lee. Por lo tanto, establece los mismos bloqueos que un UPDATE de SQL establecería en las filas.
Alternativamente, los usuarios pueden usar sentencias SELECT ... FOR SHARE, como explican los documentos, SELECT ... FOR SHARE:
Establece un bloqueo en modo compartido en cualquier fila que se lea. Otras sesiones pueden leer las filas, pero no modificarlas hasta que tu transacción se confirme. Si alguna de estas filas fue cambiada por otra transacción que aún no se ha confirmado, tu consulta esperará hasta que esa transacción termine y luego usará los valores más recientes.
Ent recientemente añadió soporte para sentencias FOR SHARE/FOR UPDATE mediante una bandera de característica llamada sql/lock. Para usarla, modifica tu archivo generate.go para incluir --feature sql/lock:
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/lock ./schema
A continuación, implementemos una función que use bloqueo pesimista para asegurar que solo un proceso pueda actualizar el campo online de nuestro objeto User:
func pessimisticUpdate(tx *ent.Tx, id int, online bool) (*ent.User, error) {
ctx := context.Background()
// On our active transaction, we begin a query against the user table
u, err := tx.User.Query().
// We add a predicate limiting the lock to the user we want to update.
Where(user.ID(id)).
// We use the ForUpdate method to tell ent to ask our DB to lock
// the returned records for update.
ForUpdate(
// We specify that the query should not wait for the lock to be
// released and instead fail immediately if the record is locked.
sql.WithLockAction(sql.NoWait),
).
Only(ctx)
// If we failed to acquire the lock we do not proceed to update the record.
if err != nil {
return nil, err
}
// Finally, we set the online field to the desired value.
return u.Update().SetOnline(online).Save(ctx)
}
Ahora escribamos una prueba que verifique que si dos procesos intentan editar el mismo registro, solo uno tendrá éxito:
func TestPessimistic(t *testing.T) {
ctx := context.Background()
client := enttest.Open(t, dialect.MySQL, "root:pass@tcp(localhost:3306)/test?parseTime=True")
// Create the user for the first time.
orig := client.User.Create().SetOnline(true).SaveX(ctx)
// Open a new transaction. This transaction will acquire the lock on our user record.
tx, err := client.Tx(ctx)
if err != nil {
log.Fatalf("failed creating transaction: %v", err)
}
defer tx.Commit()
// Open a second transaction. This transaction is expected to fail at
// acquiring the lock on our user record.
tx2, err := client.Tx(ctx)
if err != nil {
log.Fatalf("failed creating transaction: %v", err)
}
defer tx.Commit()
// The first update is expected to succeed.
if _, err := pessimisticUpdate(tx, orig.ID, true); err != nil {
log.Fatalf("unexpected error: %s", err)
}
// Because we did not run tx.Commit yet, the row is still locked when
// we try to update it a second time. This operation is expected to
// fail.
_, err = pessimisticUpdate(tx2, orig.ID, true)
if err == nil {
log.Fatal("expected second update to fail")
}
fmt.Println(err)
}
Algunos detalles importantes en este ejemplo:
Nota que usamos una instancia real de MySQL para ejecutar esta prueba, ya que SQLite no soporta
SELECT .. FOR UPDATE.Por simplicidad del ejemplo, usamos la opción
sql.NoWaitpara indicar a la base de datos que devuelva un error si no se puede adquirir el bloqueo. Esto significa que la aplicación llamante necesita reintentar la escritura después de recibir el error. Si no especificamos esta opción, podemos crear flujos donde nuestra aplicación se bloquea hasta que se libera el bloqueo y luego continúa sin reintentos. Esto no siempre es deseable pero abre opciones de diseño interesantes.Siempre debemos confirmar (commit) nuestra transacción. Olvidar hacerlo puede causar problemas graves. Recuerda que mientras se mantiene el bloqueo, nadie puede leer o actualizar este registro.
Ejecutando nuestra prueba:
=== RUN TestPessimistic
Error 3572: Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.
--- PASS: TestPessimistic (0.08s)
¡Genial! Hemos usado las capacidades de "lecturas con bloqueo" de MySQL y el nuevo soporte de Ent para implementar un mecanismo de bloqueo que proporciona garantías reales de exclusión mutua.
Conclusión
Comenzamos este post presentando el tipo de requisitos empresariales que llevan a los desarrolladores a usar técnicas de bloqueo con bases de datos. Continuamos presentando dos enfoques diferentes para lograr exclusión mutua al actualizar registros y demostramos cómo emplear estas técnicas usando Ent.
¿Tienes preguntas? ¿Necesitas ayuda para empezar? Únete a nuestro servidor de Discord o canal de Slack.
- Suscríbete a nuestro Newsletter
- Síguenos en Twitter
- Únete a #ent en Gophers Slack
- Únete al Ent Discord Server