Django: ¿Cómo puedo protegerme contra la modificación simultánea de las entradas de la base de datos?

81

¿Hay alguna forma de protegerse contra modificaciones simultáneas de la misma entrada de la base de datos por parte de dos o más usuarios?

Sería aceptable mostrar un mensaje de error al usuario que realiza la segunda operación de confirmación / guardado, pero los datos no deben sobrescribirse en silencio.

Creo que bloquear la entrada no es una opción, ya que un usuario puede usar el botón "Atrás" o simplemente cerrar su navegador, dejando el candado para siempre.

Ber
fuente
4
Si un objeto puede ser actualizado por varios usuarios simultáneos, es posible que tenga un problema de diseño mayor. Podría valer la pena pensar en los recursos específicos del usuario o separar los pasos de procesamiento en tablas separadas para evitar que esto sea un problema.
S.Lott

Respuestas:

48

Así es como hago el bloqueo optimista en Django:

updated = Entry.objects.filter(Q(id=e.id) && Q(version=e.version))\
          .update(updated_field=new_value, version=e.version+1)
if not updated:
    raise ConcurrentModificationException()

El código enumerado anteriormente se puede implementar como método en Custom Manager .

Estoy haciendo las siguientes suposiciones:

  • filter (). update () dará como resultado una única consulta de base de datos porque el filtro es lento
  • una consulta de base de datos es atómica

Estas suposiciones son suficientes para garantizar que nadie más haya actualizado la entrada antes. Si se actualizan varias filas de esta manera, debe utilizar transacciones.

ADVERTENCIA Django Doc :

Tenga en cuenta que el método update () se convierte directamente en una declaración SQL. Es una operación masiva para actualizaciones directas. No ejecuta ningún método save () en sus modelos, ni emite las señales pre_save o post_save

Andrei Savu
fuente
12
¡Agradable! Sin embargo, ¿no debería ser '&' en lugar de '&&'?
Giles Thomas
1
¿Podría eludir el problema de que 'update' no ejecuta los métodos save () poniendo la llamada a 'update' dentro de su propio método save () anulado?
Jonathan Hartley
1
¿Qué sucede cuando dos subprocesos llaman al mismo tiempo filter, ambos reciben una lista idéntica sin modificar ey luego ambos llaman al mismo tiempo update? No veo ningún semáforo que bloquee el filtrado y la actualización simultáneamente. EDITAR: oh, ahora entiendo el filtro perezoso. Pero, ¿cuál es la validez de asumir que update () es atómico? seguramente la base de datos maneja el acceso concurrente
totowtwo
1
@totowtwo La I en ACID garantiza el pedido ( en.wikipedia.org/wiki/ACID ). Si una ACTUALIZACIÓN se está ejecutando en datos relacionados con un SELECCIONAR concurrente (pero luego iniciado), se bloqueará hasta que la ACTUALIZACIÓN esté completa. Sin embargo, se pueden ejecutar múltiples SELECT al mismo tiempo.
Kit Sunde
1
Parece que esto funcionará correctamente solo con el modo de confirmación automática (que es el predeterminado). De lo contrario, COMMIT final se separará de esta declaración SQL de actualización, por lo que se puede ejecutar código simultáneo entre ellos. Y tenemos el nivel de aislamiento ReadCommited en Django, por lo que leerá la versión anterior. (Por qué quiero una transacción manual aquí, porque quiero crear una fila en otra tabla junto con esta actualización). Sin embargo, es una gran idea.
Alex Lokk
39

Esta pregunta es un poco vieja y mi respuesta un poco tardía, pero después de lo que entiendo, esto se ha solucionado en Django 1.4 usando:

select_for_update(nowait=True)

ver los documentos

Devuelve un conjunto de consultas que bloqueará filas hasta el final de la transacción, generando una instrucción SQL SELECT ... FOR UPDATE en las bases de datos compatibles.

Por lo general, si otra transacción ya ha adquirido un bloqueo en una de las filas seleccionadas, la consulta se bloqueará hasta que se libere el bloqueo. Si este no es el comportamiento que desea, llame a select_for_update (nowait = True). Esto hará que la llamada no se bloquee. Si otra transacción ya ha adquirido un bloqueo en conflicto, se generará DatabaseError cuando se evalúe el conjunto de consultas.

Por supuesto, esto solo funcionará si el back-end admite la función "seleccionar para actualización", que por ejemplo, sqlite no lo hace. Desafortunadamente: nowait=Trueno es compatible con MySql, allí debe usar:, nowait=Falseque solo se bloqueará hasta que se libere el bloqueo.

giZm0
fuente
2
Esta no es una gran respuesta: la pregunta explícitamente no quería bloqueo (pesimista), y las dos respuestas más votadas actualmente se centran en el control de concurrencia optimista ("bloqueo optimista") por esa razón. Sin embargo, seleccionar para actualizar está bien en otras situaciones.
RichVel
@ giZm0 Eso todavía lo convierte en un bloqueo pesimista. El primer hilo que obtenga el candado puede retenerlo indefinidamente.
knaperek
6
Me gusta esta respuesta porque es de la documentación de Django y no es una hermosa invención de un tercero.
anizzomc
29

En realidad, las transacciones no le ayudan mucho aquí ... a menos que desee que las transacciones se ejecuten en varias solicitudes HTTP (que probablemente no desee).

Lo que usamos habitualmente en esos casos es "Bloqueo optimista". El ORM de Django no lo admite hasta donde yo sé. Pero ha habido alguna discusión sobre la adición de esta función.

Así que estás solo. Básicamente, lo que debe hacer es agregar un campo de "versión" a su modelo y pasarlo al usuario como un campo oculto. El ciclo normal para una actualización es:

  1. leer los datos y mostrarlos al usuario
  2. usuario modificar datos
  3. el usuario publica los datos
  4. la aplicación lo vuelve a guardar en la base de datos.

Para implementar el bloqueo optimista, cuando guarda los datos, verifica si la versión que recibió del usuario es la misma que la de la base de datos, y luego actualice la base de datos e incremente la versión. Si no es así, significa que ha habido un cambio desde que se cargaron los datos.

Puede hacer eso con una sola llamada SQL con algo como:

UPDATE ... WHERE version = 'version_from_user';

Esta llamada actualizará la base de datos solo si la versión sigue siendo la misma.

Guillaume
fuente
1
Esta misma pregunta también apareció en Slashdot. El bloqueo optimista que sugiere también se propuso allí, pero se explicó un poco mejor en mi humilde opinión: hardware.slashdot.org/comments.pl?sid=1381511&cid=29536367
hopla
5
También tenga en cuenta que desea utilizar transacciones además de esto, para evitar esta situación: hardware.slashdot.org/comments.pl?sid=1381511&cid=29536613 Django proporciona middleware para envolver automáticamente cada acción en la base de datos en una transacción, comenzando desde la solicitud inicial y solo se confirma después de una respuesta exitosa: docs.djangoproject.com/en/dev/topics/db/transactions ( tenga en cuenta: el middleware de transacciones solo ayuda a evitar el problema anterior con bloqueo optimista, no proporciona bloqueo por sí mismo)
hopla
También estoy buscando detalles sobre cómo hacer esto. Hasta ahora no tuve suerte.
seanyboy
1
puede hacer esto usando actualizaciones masivas de django. revisa mi respuesta.
Andrei Savu
14

Django 1.11 tiene tres opciones convenientes para manejar esta situación según los requisitos de la lógica de su negocio:

  • Something.objects.select_for_update() bloqueará hasta que el modelo quede libre
  • Something.objects.select_for_update(nowait=True)y detectar DatabaseErrorsi el modelo está actualmente bloqueado para actualizar
  • Something.objects.select_for_update(skip_locked=True) no devolverá los objetos que están actualmente bloqueados

En mi aplicación, que tiene flujos de trabajo interactivos y por lotes en varios modelos, encontré estas tres opciones para resolver la mayoría de mis escenarios de procesamiento concurrente.

La "espera" select_for_updatees muy conveniente en los procesos secuenciales por lotes; quiero que se ejecuten todos, pero déjeles que se tomen su tiempo. Se nowaitusa cuando un usuario desea modificar un objeto que actualmente está bloqueado para actualizarse; solo les diré que se está modificando en este momento.

El skip_lockedes útil para otro tipo de actualización, cuando los usuarios pueden activar una nueva exploración de un objeto, y no me importa quién lo active, siempre que se active, por lo skip_lockedque me permite omitir silenciosamente los activadores duplicados.

Kravietz
fuente
1
¿Necesito envolver la selección para actualizar con transaction.atomic ()? ¿Si realmente estoy usando los resultados para una actualización? ¿No bloqueará toda la tabla haciendo que select_for_update sea un error?
Paul Kenjora
3

Para referencia futura, consulte https://github.com/RobCombs/django-locking . Se bloquea de una manera que no deja bloqueos eternos, mediante una combinación de desbloqueo de JavaScript cuando el usuario abandona la página y tiempos de espera de bloqueo (por ejemplo, en caso de que el navegador del usuario se bloquee). La documentación es bastante completa.

Stijn Debrouwere
fuente
3
Vaya, esta es una idea realmente extraña.
julio de 2011
1

Probablemente debería usar el middleware de transacciones de django al menos, incluso independientemente de este problema.

En cuanto a su problema real de tener varios usuarios editando los mismos datos ... sí, use el bloqueo. O:

Verifique con qué versión está actualizando un usuario (¡hágalo de forma segura, para que los usuarios no puedan simplemente piratear el sistema para decir que estaban actualizando la última copia!), Y solo actualice si esa versión es actual. De lo contrario, envíe al usuario una nueva página con la versión original que estaba editando, la versión enviada y las nuevas versiones escritas por otros. Pídales que combinen los cambios en una versión completamente actualizada. Puede intentar fusionarlos automáticamente con un conjunto de herramientas como diff + patch, pero de todos modos necesitará que el método de fusión manual funcione para los casos de falla, así que comience con eso. Además, deberá preservar el historial de versiones y permitir que los administradores reviertan los cambios, en caso de que alguien, involuntaria o intencionalmente, eche a perder la combinación. Pero probablemente deberías tener eso de todos modos.

Es muy probable que haya una aplicación / biblioteca de django que haga la mayor parte de esto por usted.

Lee B
fuente
Esto también es bloqueo optimista, como propuso Guillaume. Pero pareció obtener todos los puntos :)
hopla
0

Otra cosa a buscar es la palabra "atómico". Una operación atómica significa que el cambio de su base de datos ocurrirá con éxito o fallará obviamente. Una búsqueda rápida muestra esta pregunta sobre operaciones atómicas en Django.

Harley Holcombe
fuente
No quiero realizar una transacción o bloquear varias solicitudes, ya que esto puede llevar mucho tiempo (y es posible que nunca termine)
Ber,
Si una transacción comienza, tiene que terminar. Solo debe bloquear el registro (o iniciar la transacción, o lo que decida hacer) después de que el usuario haga clic en "enviar", no cuando abra el registro para su visualización.
Harley Holcombe
Sí, pero mi problema es diferente, ya que dos usuarios abren el mismo formulario y luego ambos confirman sus cambios. No creo que el bloqueo sea la solución para esto.
Ber
Tienes razón, pero el problema es que no es ninguna solución para esto. Un usuario gana, el otro recibe un mensaje de error. Cuanto más tarde bloquee el registro, menos problemas tendrá.
Harley Holcombe
Estoy de acuerdo. Acepto totalmente el mensaje de error para el otro usuario. Estoy buscando una buena forma de detectar este caso (que espero que sea muy raro).
Ber
0

La idea de arriba

updated = Entry.objects.filter(Q(id=e.id) && Q(version=e.version))\
      .update(updated_field=new_value, version=e.version+1)
if not updated:
      raise ConcurrentModificationException()

se ve muy bien y debería funcionar bien incluso sin transacciones serializables.

El problema es cómo aumentar el comportamiento de .save () sordo para no tener que hacer plomería manual para llamar al método .update ().

Miré la idea del Administrador personalizado.

Mi plan es anular el método Manager _update al que llama Model.save_base () para realizar la actualización.

Este es el código actual en Django 1.3

def _update(self, values, **kwargs):
   return self.get_query_set()._update(values, **kwargs)

Lo que hay que hacer en mi humilde opinión es algo como:

def _update(self, values, **kwargs):
   #TODO Get version field value
   v = self.get_version_field_value(values[0])
   return self.get_query_set().filter(Q(version=v))._update(values, **kwargs)

Algo similar debe suceder al eliminar. Sin embargo, eliminar es un poco más difícil ya que Django está implementando algo de vudú en esta área a través de django.db.models.deletion.Collector.

Es extraño que una herramienta moderna como Django carezca de orientación para Optimictic Concurency Control.

Actualizaré esta publicación cuando resuelva el acertijo. Con suerte, la solución será de una manera pitónica agradable que no implique toneladas de codificación, vistas extrañas, omitir piezas esenciales de Django, etc.

Kiril
fuente
-2

Para estar seguro, la base de datos debe admitir transacciones .

Si los campos son de "formato libre", por ejemplo, texto, etc. y necesita permitir que varios usuarios puedan editar los mismos campos (no puede tener la propiedad de un solo usuario para los datos), puede almacenar los datos originales en un variable. Cuando el usuario se compromete, verifique si los datos de entrada han cambiado de los datos originales (si no, no necesita molestar a la base de datos reescribiendo los datos antiguos), si los datos originales comparados con los datos actuales en la base de datos son los mismos puede guardar, si ha cambiado, puede mostrarle al usuario la diferencia y preguntarle qué hacer.

Si los campos son números, por ejemplo, saldo de la cuenta, número de artículos en una tienda, etc., puede manejarlo de manera más automática si calcula la diferencia entre el valor original (almacenado cuando el usuario comenzó a completar el formulario) y el nuevo valor que puede iniciar una transacción leer el valor actual y agregar la diferencia, luego finalizar la transacción. Si no puede tener valores negativos, debe cancelar la transacción si el resultado es negativo e informar al usuario.

No sé django, así que no puedo darte los cod3s ..;)

Stein G. Strindhaug
fuente
-6

Desde aquí:
Cómo evitar sobrescribir un objeto que otra persona ha modificado

Supongo que la marca de tiempo se mantendrá como un campo oculto en el formulario del que está intentando guardar los detalles.

def save(self):
    if(self.id):
        foo = Foo.objects.get(pk=self.id)
        if(foo.timestamp > self.timestamp):
            raise Exception, "trying to save outdated Foo" 
    super(Foo, self).save()
seanyboy
fuente
1
el código está roto. aún puede ocurrir una condición de carrera entre la consulta si comprobar y guardar. necesita usar objects.filter (id = .. & timestamp check) .update (...) y generar una excepción si no se actualizó ninguna fila.
Andrei Savu