sábado, enero 05, 2008

UpdateBatchSize

MarshalingTodo programador que haya trabajado con SQL Server sabe lo importante que es empaquetar cuantas instrucciones sea posible dentro de una misma petición al servidor. El mismo programador, cuando comienza a trabajar con ADO.NET tropieza con la paradoja de que cada actualización de registros modificados en un dataset, debe viajar al servidor SQL completamente sola. Al menos, en la versión 1.1 era esto lo que ocurría...

Tinieblas

El problema, aclaro, no es grave. Típicamente, cuando se producen actualizaciones a través de modificaciones en un DataSet, estas modificaciones afectan a muy pocos registros, y el peaje a pagar es poco. Por el contrario, si usted se despierta un día actualizando centenares de registros a través de un data adapter, corra a visitar a su psicólogo de cabecera. Para actualizaciones masivas, es mejor usar directamente SQL, y si es posible, mediante procedimientos almacenados. Ese mítico escenario del feliz programador OOP que transparentemente modifica instancias de clases generadas por un ORM es una gran mentira: en una aplicación real, la mayoría de las modificaciones que no se realizan en el servidor mediante procedimientos almacenados, se generan a petición de controles enlazados a datos... a no ser que usted sienta fobia hacia el enlace de datos y los procedimientos almacenados.
De todos modos, las clases SqlDataAdapter y OracleDataAdapter de .NET 2.0 introdujeron una propiedad UpdateBatchSize, que permite agrupar varias actualizaciones en una misma petición. Si la propiedad vale 1, que es su valor por omisión, el comportamiento de los adaptadores es el de la versión anterior: una petición separada para cada registro actualizado. Un 0 en la propiedad provoca que todas las actualizaciones se sumen y se envíen en un único viaje de ida y vuelta. Y, finalmente, cualquier valor mayor que 1 sirve para crear grupos de peticiones del tamaño indicado.
Confieso, sin embargo, que hasta hace muy poco, nunca había usado esta propiedad. El motivo tiene que ver con la mecánica de las actualizaciones en ADO.NET. Suponga que tratamos con un una tabla cuya clave primaria es una columna autoincremental, y que tiene un campo de tipo rowversion/timestamp para el control optimista de concurrencia. Las inserciones en esta tabla se ejecutan mediante el siguiente grupo de instrucciones SQL:
insert into Clientes(...) values (...);
select IDCliente, TS from Clientes
where IDCliente = SCOPE_IDENTITY()
En otras palabras: cuando se inserta un registro, inmediatamente después se pide la última identidad creada al servidor, y el valor de la versión de fila para la nueva fila. Estos dos valores se devuelven al adaptador como un recordset de una fila, y el adaptador utiliza estos valores para poner al día las correspondientes columnas en el dataset.
¿Qué ocurriría si agrupásemos varias de estas instrucciones en un mismo batch o grupo de consultas? Evidentemente, el mecanismo saltaría por los aires. El servidor respondería enviando una colección de recordsets, pero el adaptador sería incapaz de distinguir cuál correspondería a cada fila. Es natural que el programador se abstenga de usar UpdateBatchSize, pues aparentemente obligaría a utilizar otro sistema para la puesta al día de la copia local de los datos tras una actualización.

Fiat lux

Pero, ¡pobre de mí!, no sospechaba cuán listos habían sido los que añadieron esta característica a los adaptadores. Hace poco, haciendo pruebas, me di cuenta de que un adaptador con UpdateBatchSize parecía seguir enviando consultas individuales al servidor SQL. Al menos, en el SQL Profiler se mostraban las inserciones como instrucciones individuales. Intrigado, busqué más información en Internet, y así supe que la labor de UpdateBatchSize no consiste en generar una única petición formada por una gigantesca instrucción. La técnica es más sofisticada, y según lo que sé, consiste en agrupar todas las peticiones en un único paquete RPC (Remote Procedure Call). Esto ahorra un ancho de banda considerable, y lo que es más importante, disminuye los problemas de latencia provocados por la red. No se optimiza la operación, considerada por separado, al máximo, pero se logra un mejor resultado, de todos modos... y abre el camino a la esperanza.
Volví entonces a la documentación de SqlDataAdapter y descubrí un pequeño detalle: cuando BatchUpdateSize es distinto de 1, los comandos del adaptador no pueden tener los valores Both o FirstReturnedRecord en su propiedad UpdateSource. Es decir: no se puede usar un recordset para poner al día la copia local. Pero esto también significa que se admite la opción OutputParameters. Si la actualización se realiza a través de procedimientos almacenados, ¡es posible usar UpdateBatchSize y seguir actualizando la copia local en una misma operación!
En estos casos, hay que tener un procedimiento almacenado en el servidor parecido a éste:
create procedure InsertCliente
@IdCliente int output,
@TS timestamp output,
@Nombre T_NOMBRE,
@Apellidos T_APELLIDOS as
insert into Clientes(Nombre, Apellidos)
values (@Nombre, @Apellidos);
select @IdCliente = IDCliente, @TS = TS
from Clientes
where IDCliente = SCOPE_IDENTITY()
Observe que esta vez no se utiliza un recordset, sino los parámetros de salida del procedimiento, para devolver los valores actualizados de las columnas modificadas en el servidor.
Evidentemente, el uso de procedimientos almacenados para estas operaciones requiere un poco más de planificación por parte del programador... pero es una tarea mecanizable, y seguramente podremos contar con la ayuda del DBA. Tenga en cuenta que el DBA no protestará si le pide que no permita actualizaciones en una tabla excepto a través de un procedimiento almacenado: estas cosas hacen feliz a un administrador de bases de datos.
Es poco probable que una aplicación en dos capas, para la que se presume poca carga, se beneficie de un mecanismo tan sofisticado de actualización. Pero en una aplicación multicapas con mucha carga, cada milisegundo (¡sí, hablo de milisegundos!) que se gane en una operación, significa más capacidad de concurrencia para la capa intermedia: el modelo de concurrencia de una capa intermedia con pooling no ofrece capacidad lineal respecto a los tiempos de respuesta. El truco de combinar UpdateBatchSize con procedimientos almacenados puede parecer excesivamente complicado a algunos, pero es bueno saber que disponemos de un arma como ésta en nuestro arsenal.

Actualización: He subido una pequeña actualización de Intuitive C#, con una nueva sección sobre métodos de extensión. Lo único interesante es que he añadido la sección al capítulo de interfaces.

Etiquetas: , ,

domingo, diciembre 30, 2007

Vade retro, LINQ pro SQL

Hágame caso, y evitará malgastar sus fuerzas: si quiere desarrollar una aplicación seria, apártese de LINQ para SQL. Al menos, de momento...
Llevo varios días descubriendo cosas que no me gustan, pero me he contenido por prudencia. El descubrimiento que ha puesto el último clavo sobre el ataúd del invento tiene que ver con la forma en que se manejan las relaciones maestro/detalles. Pongamos por caso que tenemos dos tablas relacionadas, como Clientes y Pedidos:
LINQ para SQL crea dos clases, con un formato predecible:
public partial class Customer
{
public EntitySet<Order> Orders { ... }
}

public partial class Order
{
public Customer Customer { ... }
}
¿En qué momento se leen los pedidos de un cliente? Por omisión, no cuando se crea el cliente; ni siquiera cuando se accede a la propiedad Orders. Hay que intentar hacer algo con el contenido de la colección para que se dispare la consulta SQL correspondiente, que se parecerá a la siguiente:
select * from orders
where CustomerID = @CustomerID
Hasta aquí, todo es esperable, predecible y aceptable... suponiendo que estamos creando una aplicación con la vetusta arquitectura cliente/servidor. Si vamos a tener cuatro gatos conectados a un servidor local de SQL Server (pues LINQ para SQL sólo soporta, de momento, SQL Server), las cosas nos irán bien. Pero para eso no hacía falta gastar dinero en una subscripción anual a la MSDN. Cómprese un Delphi 7 de segunda o tercera mano, o incluso un Visual Basic "clásico", y sea feliz.
Pero, ¿qué pasa con la programación seria? Cuando usted se trae los datos de un grupo de clientes a la capa de presentación, tiene dos opciones, teóricamente: la carga incremental, o por demanda, de sus pedidos, o traer todos los pedidos en la misma operación que trae el grupo de clientes. En mi libro particular de estilo, la carga "por demanda" no sería aceptable. Cuando alguien pide clientes y sus pedidos, es porque realmente necesita ambas entidades, y en programación multicapas, el pecado mortal de necesidad se llama round-trip; es decir, viaje de ida y vuelta al servidor de capa intermedia. Pero mantengamos las opciones abiertas, y supongamos que cada técnica tiene su uso. Pues bien:
  1. LINQ para SQL no permite implementar la carga por demanda cuando hay una capa intermedia. Para transmitir un registro de cliente, hay que "serializarlo", y eso implica serializar sus pedidos. Adiós lectura incremental de los pedidos.
  2. Y ahora viene lo peor: supongamos que ya estamos resignados a no usar la carga por demanda. El caso es que LINQ para SQL implementa la lectura, en esos casos, de manera asquerosamente ineficiente.
Aquí aparecerá el listo de siempre, que alegará la existencia de unas DataLoadOptions que permitirían desactivar la carga por demanda. En efecto, LINQ para SQL ofrece algo así:
DataLoadOptions dlo = new DataLoadOptions();
dlo.LoadWith<Customer>(c => c.Orders);
dataContext.LoadOptions = dlo;
Pero, si se molestase en comprobar qué es lo que realmente hace dicha opción, comprobaría que, si se leen cuatrocientos clientes, esta opción provocaría que el servidor de capa intermedia lanzase otras cuatrocientos consultas SQL para leer individualmente los pedidos de cada cliente. Sí, es verdad que esta avalancha se produciría de golpe, en vez de incrementalmente...
Las cosas son buenas o malas en comparación con otras. ¿Cómo se resuelve este problema en ADO.NET con los conjuntos de datos de toda la vida? Muy simple: usted sólo necesita lanzar dos consultas, y es ADO.NET quien se encarga de tejer la relación en memoria, una vez que dispone de todos los registros. ¿Quiere, por ejemplo, los clientes de Madrid y sus pedidos? En un mismo comando (¡para ahorrar aún más!) se incluirían dos consultas:
select * from Customers
where City = "Madrid";
select * from Orders
where CustomerID in (
select CustomerID from Customers
where City = "Madrid")
A SQL Server se le da muy bien responder a este tipo de consultas, a pesar de su aparente complejidad. Y no le digo nada sobre recuperar los clientes que hicieron pedidos la semana anterior: la ganancia comparativa sería escandalosa.
¿Conclusión? Pues que LINQ para SQL no está preparado para la vida real. Es un juguetito con ínfulas, que a la hora de la verdad se queda muy corto... y es lamentable que la comunidad de programadores no esté diciéndolo en voz alta. No se trata sólo del problema aquí explicado: hay que sumar los problemas que ya la propia Microsoft explica para el funcionamiento del sistema en tres capas. Por ejemplo: los valores originales de cada registro se almacenan en el contexto, y al serializar un registro se pierden, no existe una funcionalidad comparable al Merge de los conjuntos de datos...
Es cierto que Microsoft se reserva eso del ADO.NET Entity Framework para febrero o marzo (es significativo que hayan publicado Visual Studio 2008 sin esperar por él). Habrá que esperar al producto final antes pronunciarse. Pero cada vez creo menos en la bondad de los ORMs: tengo razones para esperar muy poco de este tipo de sistemas. De todos modos, seré prudente: ya veremos qué tal se comporta el nuevo invento. A lo mejor esta es la vez en que un ORM acierta por primera vez en la Historia...

... y que ahora alguien me diga cuál es la urgencia para aprender a usar LINQ. ¿Para aplicarlo al famoso LINQ para SQL?
Mucho cuidado: no estoy diciendo que no merezca pasarse a VS2008. Todo lo contrario, ¡pásese urgentemente a la nueva versión! Hay suficientes mejoras como para merecer la pena, y la principal de ellas, en mi humilde opinión, es la nueva técnica de generación de clases para conjuntos de datos, que facilita enormemente la creación de aplicaciones en tres capas.

Etiquetas: , , , ,