database - patrones - ¿Cómo funciona la desnormalización de datos con el patrón de microservicio?
modelado y diseño de microservicios (4)
Acabo de leer un artículo sobre Microservicios y Arquitectura PaaS . En ese artículo, alrededor de un tercio del camino hacia abajo, el autor afirma (bajo Denormalize like Crazy ):
Refactorizar los esquemas de la base de datos y desnormalizar todo, para permitir la separación completa y la partición de los datos. Es decir, no use tablas subyacentes que sirvan a múltiples microservicios. No debe haber intercambio de tablas subyacentes que abarquen múltiples microservicios y no se compartan datos. En cambio, si varios servicios necesitan acceder a los mismos datos, deben compartirse a través de una API de servicio (como un REST publicado o una interfaz de servicio de mensajes).
Si bien esto suena muy bien en teoría, en la práctica tiene algunos obstáculos serios que superar. La mayor de ellas es que, a menudo, las bases de datos están estrechamente vinculadas y cada tabla tiene alguna relación de clave externa con al menos otra tabla. Debido a esto, podría ser imposible dividir una base de datos en n bases de datos controladas por n microservicios.
Entonces pregunto: dada una base de datos que consiste completamente de tablas relacionadas, ¿cómo se puede desnormalizar esto en fragmentos más pequeños (grupos de tablas) para que los fragmentos puedan ser controlados por microservicios separados?
Por ejemplo, dada la siguiente base de datos (bastante pequeña, pero ejemplar):
[users] table
=============
user_id
user_first_name
user_last_name
user_email
[products] table
================
product_id
product_name
product_description
product_unit_price
[orders] table
==============
order_id
order_datetime
user_id
[products_x_orders] table (for line items in the order)
=======================================================
products_x_orders_id
product_id
order_id
quantity_ordered
No dedique demasiado tiempo a criticar mi diseño, lo hice sobre la marcha. El punto es que, para mí, tiene sentido lógico dividir esta base de datos en 3 microservicios:
-
UserService
: para los usuarios de CRUDding en el sistema; en última instancia, debe administrar la tabla[users]
; y -
ProductService
: para productos CRUDding en el sistema; en última instancia, debe administrar la tabla[products]
; y -
OrderService
: para órdenes CRUDDING en el sistema; en última instancia, debe administrar las tablas[orders]
y[products_x_orders]
[orders]
Sin embargo, todas estas tablas tienen relaciones de clave externa entre sí. Si los desnormalizamos y los tratamos como monolitos, pierden todo su significado semántico:
[users] table
=============
user_id
user_first_name
user_last_name
user_email
[products] table
================
product_id
product_name
product_description
product_unit_price
[orders] table
==============
order_id
order_datetime
[products_x_orders] table (for line items in the order)
=======================================================
products_x_orders_id
quantity_ordered
Ahora no hay manera de saber quién ordenó qué, en qué cantidad o cuándo.
Entonces, ¿este artículo es una algarabía académica típica, o hay una practicidad en el mundo real para este enfoque de desnormalización, y si es así, qué aspecto tiene (puntos de bonificación por usar mi ejemplo en la respuesta)?
De hecho, es uno de los problemas clave en microservicios que se omite de manera bastante convincente en la mayoría de los artículos. Afortunadamente hay soluciones para esto. Como base para la discusión, tengamos tablas que haya proporcionado en la pregunta. La imagen de arriba muestra cómo se verán las tablas en monolito. Solo algunas tablas con combinaciones.
Para refactorizar esto a microservicios, podemos usar algunas estrategias:
Api Únase
En esta estrategia, las claves externas entre los microservicios se rompen y el microservicio expone un punto final que imita esta clave. Por ejemplo: el microservicio del producto expondrá el extremo de findProductById
. El microservicio de pedido puede usar este punto final en lugar de unirse.
Tiene un inconveniente obvio. Es mas lento
Vistas de solo lectura
En la segunda solución, puede crear una copia de la tabla en la segunda base de datos. La copia es de solo lectura. Cada microservicio puede usar operaciones mutables en sus tablas de lectura / escritura. Cuando se trata de leer solo tablas que se copian de otras bases de datos, pueden (obviamente) usar solo lecturas
Lectura de alto rendimiento
Es posible lograr un alto rendimiento de lectura al introducir soluciones como redis / memcached en la parte superior de la solución de read only view
. Ambos lados de la unión deben copiarse en una estructura plana optimizada para la lectura. Puede introducir un servicio de microsistema sin estado completamente nuevo que puede usarse para leer desde este almacenamiento. Si bien parece una gran molestia, vale la pena señalar que tendrá un mayor rendimiento que la solución monolítica sobre la base de datos relacional.
Hay pocas soluciones posibles. Los que son los más simples en implementación tienen un rendimiento más bajo. Las soluciones de alto rendimiento tardarán algunas semanas en implementarse.
Vería cada microservicio como un Objeto y, al igual que cualquier ORM, utilizará esos objetos para extraer los datos y luego crear uniones dentro de su código y colecciones de consultas. Microservicios debería manejarse de manera similar. La única diferencia aquí será que cada microservicio representará un objeto a la vez que un árbol de objetos completo. Una capa de API debería consumir estos servicios y modelar los datos de una manera que se deben presentar o almacenar.
Devolver varias llamadas a los servicios para cada transacción no tendrá impacto ya que cada servicio se ejecuta en un contenedor separado y todas estas calles se pueden ejecutar de forma paralela.
@ ccit-spence, me gustó el enfoque de los servicios de intersección, pero ¿cómo puede ser diseñado y consumido por otros servicios? Creo que creará una especie de dependencia para otros servicios.
¿Algún comentario por favor?
Esto es subjetivo, pero la siguiente solución funcionó para mí, mi equipo y nuestro equipo de DB.
- En la capa de aplicación, los microservicios se descomponen en función semántica.
- por ejemplo, un servicio de
Contact
podría tener contactos de CRUD (metadatos sobre contactos: nombres, números de teléfono, información de contacto, etc.) - por ejemplo, un servicio de
User
podría CRUD usuarios con credenciales de inicio de sesión, roles de autorización, etc. - por ejemplo, un servicio de
Payment
podría pagar CRUD y funcionar bajo el capó con un servicio de terceros compatible con PCI como Stripe, etc.
- por ejemplo, un servicio de
- En la capa DB, las tablas se pueden organizar, pero los desarrolladores / DBs / devops quieren que las tablas estén organizadas
El problema es con límites en cascada y de servicio: los pagos pueden necesitar que un usuario sepa quién realiza un pago. En lugar de modelar sus servicios de esta manera:
interface PaymentService {
PaymentInfo makePayment(User user, Payment payment);
}
Modelarlo así:
interface PaymentService {
PaymentInfo makePayment(Long userId, Payment payment);
}
De esta forma, las entidades que pertenecen a otros microservicios solo se referencian dentro de un servicio particular por ID, no por referencia de objeto. Esto permite que las tablas de BD tengan claves externas por todas partes, pero en la capa de aplicaciones las entidades "extranjeras" (es decir, entidades que viven en otros servicios) están disponibles a través de ID. Esto evita que la cascada de objetos crezca fuera de control y delimita limpiamente los límites del servicio.
El problema en el que incurre es que requiere más llamadas de red. Por ejemplo, si le di a cada entidad de Payment
una referencia de User
, podría obtener un pago particular para el usuario con una sola llamada:
User user = paymentService.getUserForPayment(payment);
Pero usando lo que estoy sugiriendo aquí, necesitarás dos llamadas:
Long userId = paymentService.getPayment(payment).getUserId();
User user = userService.getUserById(userId);
Esto puede ser un factor decisivo. Pero si eres inteligente e implementas el almacenamiento en caché, e implementas microservicios bien diseñados que responden en 50 a 100 ms cada llamada, no tengo dudas de que estas llamadas de red adicionales pueden ser creadas para no incurrir en latencia para la aplicación.
Me doy cuenta de que posiblemente esta no sea una buena respuesta, pero qué diablos. Tu pregunta fue:
Dada una base de datos que consiste completamente en tablas relacionadas, ¿cómo se puede desnormalizar esto en fragmentos más pequeños (grupos de tablas)?
WRT el diseño de la base de datos diría "no se puede sin eliminar claves externas" .
Es decir, las personas que presionan a Microservicios con la regla estricta de DB no compartida piden a los diseñadores de bases de datos que renuncien a las claves externas (y lo hacen implícita o explícitamente). Cuando no declaran explícitamente la pérdida de FK, uno se pregunta si realmente conocen y reconocen el valor de las claves externas (porque a menudo no se menciona en absoluto).
He visto grandes sistemas divididos en grupos de tablas. En estos casos, puede haber A) no se permiten FK entre los grupos o B) un grupo especial que contiene tablas "centrales" a las que los FK pueden hacer referencia en tablas de otros grupos.
... pero en estos sistemas "grupos de tablas" suele ser de más de 50 tablas, por lo que no es lo suficientemente pequeño como para cumplir estrictamente con los microservicios.
Para mí, el otro tema relacionado a considerar con el enfoque de Microservicio para dividir el DB es el impacto que esto tiene que reportar, la cuestión de cómo se reúnen todos los datos para informar y / o cargar en un almacén de datos.
Algo relacionado también es la tendencia a ignorar las funciones integradas de replicación de BD a favor de la mensajería (y cómo la replicación basada en la base de datos de las tablas centrales / kernel compartido de DDD) impacta el diseño.
EDITAR: (el costo de JOIN mediante llamadas REST)
Cuando dividimos el DB como lo sugieren los microservicios y eliminamos los FK, no solo perdemos la regla de negocio declarativo (del FK), sino que también perdemos la capacidad de que el DB realice la (s) unión (es) a través de esos límites.
En OLTP, los valores de FK generalmente no son "amigables para UX" y a menudo queremos unirnos a ellos.
En el ejemplo, si buscamos los últimos 100 pedidos, probablemente no queremos mostrar los valores de identificación del cliente en el UX. En su lugar, debemos hacer una segunda llamada al cliente para obtener su nombre. Sin embargo, si también queríamos las líneas de pedido, también necesitamos hacer otra llamada al servicio de productos para mostrar el nombre del producto, sku, etc., en lugar de la identificación del producto.
En general, podemos encontrar que cuando dividimos el diseño de la base de datos de esta manera, necesitamos hacer muchas llamadas "ÚNASE mediante RESTO". Entonces, ¿cuál es el costo relativo de hacer esto?
Historia real: costos de ejemplo para ''JOIN via REST'' vs DB Joins
Hay 4 microservicios e implican una gran cantidad de "UNIRSE mediante RESTO". Una carga de referencia para estos 4 servicios llega a ~ 15 minutos . Esos 4 microservicios convertidos en 1 servicio con 4 módulos contra un DB compartido (que permite uniones) ejecutan la misma carga en ~ 20 segundos .
Desafortunadamente, esta no es una comparación directa de manzanas a manzanas para DB join vs "JOIN via REST", ya que en este caso también cambiamos de DB NoSQL a Postgres.
¿Es una sorpresa que "JOIN via REST" tenga un rendimiento relativamente bajo en comparación con un DB que tenga un optimizador basado en costos, etc.
Hasta cierto punto, cuando dividimos la base de datos de esta manera, también nos alejamos del ''optimizador basado en costos'' y todo lo que hacemos con la planificación de ejecución de consultas para nosotros a favor de escribir nuestra propia lógica de unión (de alguna manera estamos escribiendo nuestra propia plan de ejecución de consultas poco sofisticado).