¿Cómo funciona el control de concurrencia optimista (columnas de versión) en el código de la aplicación, y cuándo le gana al locking pesimista?
Una columna de versión convierte un UPDATE común en un compare-and-swap nativo de la base de datos — sin ningún lock sostenido, sólo un reintento cuando alguien más llegó primero.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo funciona el control de concurrencia optimista (columnas de versión) en el código de la aplicación, y cuándo le gana al locking pesimista?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Dos filosofías para el mismo problema
Todo esquema de control de concurrencia responde la misma pregunta: ¿qué pasa cuando dos transacciones intentan modificar la misma fila en momentos que se solapan? El locking pesimista responde evitando que el conflicto ocurra siquiera: SELECT ... FOR UPDATE toma un lock de fila al momento de leer, y cualquier otra transacción que quiera escribir esa fila simplemente espera hasta que el lock se libere. El control de concurrencia optimista (OCC) responde dejando que el conflicto ocurra y detectándolo al momento de escribir: leé la fila —incluyendo un marcador de versión— sin bloquear nada, hacé tu trabajo, y cuando escribís, condicioná la escritura a que la versión siga coincidiendo con la que leíste originalmente. Si no coincide, alguien te ganó de mano; perdiste la carrera, y reintentás o fallás.
#Cómo funciona realmente la columna de versión
Agregá una columna version (un entero incremental es mucho más común y libre de races que un timestamp) a la tabla. Cada update hace dos cosas de forma atómica, en un solo viaje de ida y vuelta: incrementa la versión, y restringe el WHERE a la versión que leíste por última vez.
UPDATE accountsSET balance = :newBalance, version = version + 1WHERE id = :accountId AND version = :versionYouRead
Como el incremento-y-chequeo pasa dentro de un solo statement UPDATE ... WHERE version = ?, la propia base de datos provee la atomicidad — no necesitás una transacción explícita envolviendo la lectura y la escritura únicamente para garantizar correctitud contra lost updates, porque el compare-and-swap está embebido en el mismo statement que la escritura. Es efectivamente un compare-and-swap nativo de la base de datos:
- Si el
UPDATEafecta exactamente 1 fila, tu escritura ganó; la versión avanzó bajo tu control, atómicamente. - Si afecta 0 filas, alguien más actualizó (y re-versionó) la fila entre tu lectura y tu escritura — un conflicto. El manejo estándar: releer la fila actual, reaplicar el cambio que querías hacer contra el nuevo estado, reintentar la escritura, generalmente con un límite de reintentos y backoff exponencial.
#Código: un loop de update con reintento
package mainimport ("context""database/sql""errors""fmt""time")var ErrOptimisticLock = errors.New("optimistic lock conflict")func UpdateBalance(ctx context.Context, db *sql.DB, accountID int64, delta float64, maxRetries int) error {for attempt := 0; attempt < maxRetries; attempt++ {var balance float64var version interr := db.QueryRowContext(ctx,`SELECT balance, version FROM accounts WHERE id = $1`, accountID,).Scan(&balance, &version)if err != nil {return err}newBalance := balance + deltaresult, err := db.ExecContext(ctx,`UPDATE accountsSET balance = $1, version = version + 1WHERE id = $2 AND version = $3`,newBalance, accountID, version,)if err != nil {return err}rows, err := result.RowsAffected()if err != nil {return err}if rows == 1 {return nil}backoff := time.Duration(50*(1<<attempt)) * time.Millisecondtime.Sleep(backoff)}return fmt.Errorf("account %d: %w after %d attempts", accountID, ErrOptimisticLock, maxRetries)}
Fijate que no hay ningún BEGIN/COMMIT explícito alrededor de la lectura y la escritura acá — la seguridad viene enteramente de la cláusula WHERE version = $3, no del aislamiento de transacciones. Una transacción igual importa si necesitás que la lectura y la escritura del balance formen parte de una unidad de trabajo más grande con varios statements, pero el problema específico de lost updates ya está cerrado por el UPDATE condicional.
#Cuándo OCC le gana al locking pesimista, y cuándo no
| Situación | Mejor opción |
|---|---|
| Los conflictos son raros; la mayoría de las transacciones nunca chocan en la misma fila | Optimista — evita pagar overhead de lock en cada lectura |
| Mucho "tiempo de pensar" del usuario entre lectura y escritura, ej. editar un formulario a través de un round trip HTTP | Optimista — un lock pesimista sostenido durante el tiempo de pensar del usuario dejaría sin atender a todas las demás requests |
| Alta contención sobre las mismas filas, ej. un contador caliente o un SKU de inventario muy popular | Pesimista — los reintentos optimistas bajo contención alta pueden generar thrashing y, en patrones malos, dejar sin atención a algunos llamadores |
| Escritores distribuidos o de microservicios que no pueden mantener abierta una sola conexión a la base durante todo el lock | Optimista — no hay lock que cuidar a través de un límite de red |
| Invariantes multi-fila que nunca deben observarse en un estado inconsistente | Locking pesimista o aislamiento serializable — una columna de versión sólo protege la(s) fila(s) que la llevan, no un invariante que abarca varias |
🧠 Check your understanding
0/3 · 0/3 answered1. En un UPDATE con locking optimista como `UPDATE accounts SET balance=?, version=version+1 WHERE id=? AND version=?`, ¿qué significa que el statement reporte 0 filas afectadas?
2. ¿Qué escenario es el caso más fuerte para el control de concurrencia optimista por sobre el locking pesimista?
3. ¿Por qué el patrón de leer-y-luego-escribir-condicionalmente no necesita una transacción explícita envolviendo el SELECT y el UPDATE para ser correcto contra lost updates?