Página principal
Artículos y trucos
Catálogo de productos
Ejemplos y descargas
Mis libros
Cursos de formación
Investigación y desarrollo
Libros recomendados
Mis páginas favoritas
Acerca del autor
 
En colaboración con Amazon
 
Intuitive Sight

La cuarta opción

¿Hablamos sobre el bloqueo optimista? Tradicionalmente, las distintas interfaces de acceso a datos de Borland han implementado directamente tres algoritmos. Pero existe un cuarto posible algoritmo, bastante utilizado por los programadores que trabajan con SQL Server. Haré un rápido repaso a los tres métodos existentes para explicar a continuación en qué consiste la cuarta opción.

BLOQUEOS OPTIMISTAS

Un bloqueo optimista no es, hablando con propiedad, un bloqueo. Es una técnica utilizada principalmente por las interfaces de acceso a datos basadas en SQL para evitar conflictos entre actualizaciones provenientes de varios puestos de trabajo. El problema se produce a causa del uso de copias locales de los datos, en el lado cliente. Alicia abre la ventana de productos a las 9:00 de la mañana (para simplificar, supondremos que hay pocos productos en la base de datos), y lo mismo hace Roberto desde su propio ordenador. Alicia y Roberto son muy lentos trabajando, lo que nos vendrá muy bien para "ampliar" la escala del tiempo. A las 10:00 de la mañana, Roberto sube el precio de un producto. A las 11:00 de la mañana, sin haber vuelto a pedir la lista de productos al servidor, Alicia intenta modificar la descripción del mismo producto. ¿Qué debería suceder?

..."debería"... no deberíamos utilizar ese verbo. Desde un punto de vista práctico, o si prefiere, metodológico, existen unas cuantas alternativas, y todas ellas son correctas. Puede que en algunos casos, unas sean más apropiadas que otras, o más cómodas, pero no existe aquí, por lo general, una delgada línea roja que separe el Bien del Mal. Por lo tanto, es preferible explicar lo que puede suceder bajando un escalón en el nivel de abstracción, y mostrando la forma en que podrían implementarse estas operaciones.

Supongamos que el registro original del producto contenía estos datos:

IDProducto: 111; Descripcion: Tuercas y tornillos; Precio: 1.00 €

Dejemos a un lado, de momento, la forma en que Roberto aumentó el precio del producto, y concentrémonos en las acciones de Alicia. La forma más probable en que su aplicación intentará modificar la descripción será enviando una instrucción como la siguiente:

update PRODUCTOS
set    Descripcion = 'Tornillos y tuercas'
where  IDProducto = 111 and
       Descripcion = 'Tuercas y tornillos' and
       Precio = 1.00

Si Roberto no hubiese retocado el precio, esta instrucción modificaría exactamente un registro: el deseado, por supuesto. Pero al haber cambiado el precio, la instrucción no modifica ningún registro, porque ningún registro contiene todos esos valores. SQL no considera que esto sea un error: es la interfaz de acceso a datos la que tiene que reinterpretar el hecho de que no se hayan modificado registros y generar entonces el error, advirtiendo a Alicia que alguien ha modificado el registro, para que haga algo al respecto. Vamos también a postergar la explicación sobre qué cosas podría hacer Alicia en este caso.

Bien: es esta técnica la que se conoce como bloqueos optimistas. Observe que no hay bloqueos físicos por ningún lugar. De hecho, Roberto terminó su actualización hace ya una hora... En esta variante particular de la técnica, la actualización de Alicia ha generado una instrucción update, en cuya cláusula where se ha incluido una comparación para cada columna de la tabla. Si Alicia estuviese usando el viejo y obsoleto BDE, este comportamiento sería la consecuencia de asignar la constante upWhereAll en la propiedad UpdateMode de la tabla o consulta. Si Alicia fuese la feliz operadora de una aplicación hecha con DataSnap, también habría un valor upWhereAll, en una propiedad UpdateMode... pero esta vez, en un componente TDataSetProvider, ubicado en el servidor de capa intermedia.

Aunque hasta aquí la técnica es correcta (recuerde que no estamos viendo todavía lo que sucede tras el error), es cierto que no es muy cómoda para el usuario. Alicia, después de todo, sólo pretendía cambiar la descripción, ¿qué más le da el valor actual del precio? Si el programador hubiese utilizado la constante upWhereKeyOnly, la instrucción generada sería:

update PRODUCTOS
set    Descripcion = 'Tornillos y tuercas'
where  IDProducto = 111

En la cláusula where sólo se incluyen comparaciones para la clave primaria. ¿Se da cuenta de que esta vez se actualizará la descripción del producto? Es cierto también que Alicia no verá, a no ser que fuerce una relectura, el nuevo precio: para ella, las tuercas y tornillos no habrán sido víctimas de una inflación galopante. Pero, mientras Alicia no intente modificar el precio, esto nos da lo mismo.

Hay una ventaja importante al usar upWhereKeyOnly en vez de upWhereAll: la instrucción generada es más corta. En una aplicación en red, uno de los cuellos de botella más importantes es el uso del ancho de banda de la red. Mientras menos bytes pasen del cliente al servidor, mejor. De este modo, también el servidor estará más descansado, porque es más rápido compilar una instrucción breve que una potencialmente mucho más extensa. ¿Es para tanto? Pues sí, lo es: en nuestro ejemplo, la tabla de productos tiene tres columnas, solamente. Pero conozco tablas "reales" con cientos de columnas. ¿Se imagina una instrucción update con cien comparaciones en su cláusula where?

No obstante, el uso de upWhereKeyOnly provoca un grave problema, que asomaría su fea cara si Alicia intentase cambiar el precio, en vez de la descripción:

update PRODUCTOS
set    Precio = 0.50
where  IDProducto = 111

Aunque Roberto haya subido el precio hace una hora, esta instrucción "triunfará", en cualquier caso... y creo que coincidirá conmigo en que este triunfo sin advertencia es muy peligroso. Afortunadamente, existe una tercera constante que podemos utilizar en UpdateMode: upWhereChanged. Con esta constante, en la cláusula where se incluye siempre la clave primaria, y aquellas columnas que hayamos modificado en nuestra copia local:

update PRODUCTOS
set    Precio = 0.50
where  IDProducto = 111 and
       Precio = 1.0

Ahora, Alicia recibirá un error al tratar de modificar el precio, por no tener el valor actual del precio en su copia local. Sin embargo, y por fortuna, seguiría teniendo éxito al actualizar la descripción:

update PRODUCTOS
set    Descripcion = 'Tornillos y tuercas'
where  IDProducto = 111 and
       Descripción = 'Tuercas y tornillos'

Si sólo tuviesemos estos tres algoritmos de generación de actualizaciones a nuestra disposición, la mejor apuesta sería upWhereChanged, que realmente es la que se recomienda en todos los libros decentes de programación en Delphi.

¿PODEMOS HACERLO MEJOR?

De todos modos, en la lejana época del BDE, existía un mecanismo de "escape", que nos permitía utilizar otros algoritmos de generación de instrucciones de actualización: teníamos que activar las actualizaciones en caché, e interceptar el evento OnUpdateRecord. Con DataSnap, el mecanismo de "escape" es más sencillo: sólo tenemos que inteceptar el evento BeforeUpdateRecord de la clase TDataSetProvider. Es decir: no es cierto que sólo tengamos tres opciones de generación. Sabiendo esto, ¿se le ocurre alguna pega al algoritmo utilizado por upWhereChanged? Haberlas, haylas...

Compare las dos últimas instrucciones update. ¿Puede sacar un factor común a ambas, es posible expresarlas mediante una sola instrucción con parámetros? Evidentemente, no. Eso significa que, cada vez que vayamos a actualizar un registro, la instrucción que recibirá el servidor será una "novedad" para él: tendrá que compilarla, extrayendo toda la información necesaria de las tablas del sistema, antes de poder ejecutarla. Si pudiésemos utilizar una instrucción con parámetros, podríamos "preparar" la instrucción la primera vez que se ejecuta. En las siguientes actualizaciones, solamente necesitaríamos sustituir los parámetros.

Vayamos más lejos: sabemos que, normalmente, la ejecución de un procedimiento almacenado es más rápida que la ejecución de las instrucciones equivalentes, enviadas directamente al servidor. Esto es cierto para InterBase, Oracle y SQL Server; en este último sistema, de hecho, la única forma de "preparar" una consulta consiste en crear un procedimiento almacenado temporal equivalente. Precisamente, una de las técnicas preferidas por los programadores expertos es realizar las actualizaciones de registros mediante llamadas a procedimientos almacenados. Mi pregunta: ¿podemos crear un procedimiento almacenado que, respecto al funcionamiento del bloqueo optimista, funcione como la opción upWhereChanged? Es fácil darse cuenta de que no es posible.

MARCAS DE TIEMPO

Veamos ahora en qué consiste la misteriosa cuarta opción. Supongamos que añadimos una columna adicional a la tabla de productos. La llamaremos TS (por timestamp; luego veremos el porqué de este nombre) y supondremos que es de tipo entero. Para seguir fantaseando, digamos que esa columna se incrementa con cada modificación del registro. ¿Qué tal si Alicia, para actualizar el precio, utiliza la siguiente instrucción?

update PRODUCTOS
set    Precio = 0.50
where  IDProducto = 111 and
       TS = valor original de TS

Si alguien ha modificado el registro después de que Alicia obtuviese su copia local, la instrucción anterior no encontraría el registro. En este sentido, se estaría comportando como la opción upWhereAll... pero observe que la instrucción va a tener siempre dos condiciones en la cláusula where, sin importar cuántas columnas tenga la tabla de productos, y sin tener en cuenta cuántas columnas han sido actualizadas. Hemos logrado parametrizar, con sólo un par de parámetros, la cláusula where de la actualización.

Es cierto que la cláusula set sigue dependiendo de las columnas que modifiquemos, pero podríamos asumir que siempre vamos a asignar valores a todas las columnas, las hayamos modificado o no. ¿Se da cuenta de que ahora sí podríamos crear un procedimiento almacenado, con una pequeña cantidad de comparaciones en la cláusula where que localiza el registro a actualizar? Bueno, dirá usted, ¿merece la pena tanto trabajo? Podríamos crear también un procedimiento utilizando cláusulas where como las generadas por upWhereAll... aunque es cierto que tendríamos que pasar el doble de parámetros (explique por qué). Pero, si usamos InterBase, tendríamos que crear triggers para incrementar la columna TS. Lo que ganamos por un lado, puede que lo perdamos por otro. Y voy a darle la razón: no merece la pena usar esta técnica con InterBase.

¡Ah, amigo Horacio, pero hay más bases de datos en este mundo que las que corren sobre el sistema del pingüino! ¿Y si tuviésemos un sistema que mantuviese este tipo de columnas de forma eficiente? Entonces, permítame presentarle el tipo de datos timestamp... de SQL Server:

create table PRODUCTOS (
    IDProducto  integer     not null,
    Descripcion varchar(30) not null,
    Precio      money       not null,
    TS          timestamp   not null,

    primary key (IDProducto)
)

Una columna de tipo timestamp, como la del ejemplo anterior, que no admite nulos, ocupa 8 bytes en el formato del registro, y puede representar números de 64 bits de precisión. La primera regla del tipo timestamp: se mira y no se toca. No podemos modificar una columna de este tipo, ni insertar en ella un valor, explícitamente. Es SQL Server quien se encargará de inicializar la columna... y de actualizarla en cada modificación posterior. ¿Con qué valores lo hará, con valores autoincrementales? ¡Ni hablar! El mecanismo de SQL Server es mucho más potente. Internamente, el servidor mantiene una variable de 64 bits que actúa como contador del "número de latidos" del sistema. Cada vez que se produce una inserción o una actualización, esa variable se incrementa. Entonces, cuando se crear una fila con una columna timestamp, esa columna recibe el valor que tenga la variable global en ese momento, y cuando se actualice la fila, también se copiará el valor de la variable sobre la columna.

Para que pueda evaluar si esta opción le interesa o no (¡tiene sus propios problemas!), tendremos que ser más precisos con los detalles. Supongamos que tenemos una tabla de productos, definida como he mostrado antes. Necesitamos un procedimiento almacenado como el siguiente:

create procedure ActProducto
    @idProducto integer,
    @descripcion varchar(30),
    @precio money,
    @ts timestamp as
begin
    declare @filas integer

    update PRODUCTOS
    set    Descripcion = @descripcion,
           Precio = @precio
    where  IDProducto = @idProducto and
           TS = @ts

    set @filas = @@rowCount

    select Descripcion, Precio, TS
    from   PRODUCTOS
    where  IDProducto = @idProducto

    return @filas
end

Como ya mencioné, el precio a pagar por encapsular la actualización dentro de un procedimiento es que siempre tendremos que pasar los valores de todas las columnas, las hayamos modificado o no. La primera instrucción real dentro del procedimiento ejecuta una actualización, localizando el registro mediante la clave primaria... y el valor de la marca de tiempo. Puede que se localice el producto, y puede que no. Por este motivo, salvamos el valor de la variable @@rowCount, de SQL Server, que nos dice cuántas filas han sido modificadas por la última instrucción que hemos ejecutado. Tanto si hemos triunfado como si no, evaluamos una consulta que se devolverá como un conjunto de registros al cliente SQL (que en el caso de DataSnap, será el servidor de capa intermedia). Esto es necesario por los siguientes motivos:

  • Si la actualización falla, necesitamos conocer, sobre todo, el nuevo valor de la marca de tiempo. Pero tenemos también que saber el valor de las restantes columnas, para evitar la sobreescritura de las columans modificadas por otros usuarios. Observe que no he pedido el valor de la clave primaria: en mis aplicaciones, suelo utilizar claves artificiales, que nunca se modifican.
  • Incluso cuando la actualización triunfa, seguimos necesitando la relectura de la fila. Por una parte, tenemos que conocer qué nuevo valor ha tomado la marca de tiempo. Si se tratase solamente de esto, podríamos utilizar la variable global @@dbts, que devuelve la última marca de tiempo utilizada por nuestra conexión. Pero pueden interesarnos las restantes columnas, sobre todo si existen triggers que las modifican a nuestras espaldas.

Tenga en cuenta que otra posibilidad de devolver los últimos valores de la fila, consistiría en declarar los parámetros del procedimiento como parámetros de entrada y salida, y aprovecharlos para devolver los valores actualizados. De esa forma no tendríamos que capturar un conjunto de registros en el cliente SQL, que siempre es un poco más "costoso".

Resumiendo: el uso de marcas de tiempo es una técnica que tiene sentido solamente cuando el sistema de bases de datos ofrece algo de soporte para las mismas, y tal es el caso de SQL Server. Por una parte, nos permite estandarizar las cláusulas where de las actualizaciones; si renunciamos a actualizar sólo las columnas modificadas, que no es una renuncia tan enorme, podemos encapsular, de manera casi mecánica, la actualización dentro de un procedimiento almacenado. Así podríamos arañar algo de velocidad de ejecución al sistema. La parte negativa es que el bloqueo optimista se comporta como si hubiésemos utilizado la opción upWhereAll. Pero podríamos detectar los conflictos provocados por la actualización sobre columnas independientes para darles un tratamiento especial en la capa intermedia, sin que el error tuviese que llegar al usuario.

En menos palabras aún: he aquí una técnica adicional para implementar bloqueos optimistas, poco conocida en el mundo Delphi. A usted le corresponderá decidir si merece la pena usarla en su aplicación o no.