Capítulo 1. El encuentro con los sistemas complejos
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
En la primera parte de este capítulo exploramos los problemas que surgen al tratar con sistemas complejos. La Ingeniería del Caos nació de la necesidad de un sistema de software distribuido complejo. Aborda específicamente las necesidades del funcionamiento de un sistema complejo, a saber, que estos sistemas no son lineales, lo que los hace impredecibles y, a su vez, conduce a resultados no deseados. Esto suele resultarnos incómodo como ingenieros, porque nos gusta pensar que podemos planificar nuestra forma de evitar la incertidumbre. A menudo tenemos la tentación de culpar de estos comportamientos indeseables a las personas que construyen y manejan los sistemas, pero en realidad las sorpresas son una propiedad natural de los sistemas complejos. Más adelante en este capítulo nos preguntaremos si podemos extraer la complejidad del sistema y, al hacerlo, extraer con ella los comportamientos indeseables. (Spoiler: no, no podemos).
Contemplar la complejidad
Antes de que pueda decidir si la Ingeniería del Caos tiene sentido para tu sistema, tienes que entender dónde está el límite entre lo simple y lo complejo. Una forma de caracterizar un sistema es la forma en que los cambios en la entrada al sistema se corresponden con los cambios en la salida. Los sistemas simples suelen describirse como lineales. Un cambio en la entrada de un sistema lineal produce un cambio correspondiente en la salida del sistema. Muchos fenómenos naturales constituyen sistemas lineales familiares. Cuanto más fuerte lanzas una pelota, más lejos llega.
Los sistemas no lineales tienen una producción que varía enormemente en función de los cambios en las partes que los componen. El efecto látigo de es un ejemplo del pensamiento sistémico1 que capta visualmente esta interacción: un movimiento de muñeca (pequeño cambio en la entrada del sistema) hace que el extremo más alejado del látigo recorra en un instante la distancia suficiente para romper la velocidad del sonido y crear el chasquido por el que se conocen los látigos (gran cambio en la salida del sistema ).
Los efectos no lineales pueden adoptar diversas formas: los cambios en las partes del sistema pueden provocar cambios exponenciales en la producción, como las redes sociales que crecen más rápido cuando son grandes que cuando son pequeñas; o pueden provocar cambios cuánticos en la producción, como aplicar una fuerza cada vez mayor a un palo seco, que no se mueve hasta que se rompe de repente; o pueden provocar una producción aparentemente aleatoria, como una canción optimista que puede inspirar a alguien durante su entrenamiento un día, pero aburrirle al día siguiente.
Obviamente, los sistemas lineales son más fáciles de predecir que los no lineales. A menudo es relativamente fácil intuir la salida de un sistema lineal, sobre todo después de interactuar con una de las partes y experimentar la salida lineal. Por esta razón, podemos decir que los sistemas lineales son sistemas simples. En cambio, los sistemas no lineales muestran un comportamiento impredecible, sobre todo cuando coexisten varias partes no lineales. La superposición de partes no lineales puede hacer que el rendimiento del sistema aumente hasta cierto punto, y luego invierta repentinamente el curso, y después, igual de repentinamente, se detenga por completo. Decimos que estos sistemas no lineales son complejos.
Otra forma de caracterizar los sistemas es menos técnica y más subjetiva, pero probablemente más intuitiva. Un sistema simple es aquel en el que una persona puede comprender todas sus partes, cómo funcionan y cómo contribuyen al resultado. Un sistema complejo, por el contrario, tiene tantas partes móviles, o las partes cambian tan rápidamente que ninguna persona es capaz de mantener un modelo mental del mismo en su cabeza. Véase el Cuadro 1-1.
Sistemas sencillos | Sistemas complejos |
---|---|
Lineal | No lineal |
Resultados predecibles | Comportamiento imprevisible |
Comprensible | Imposible construir un modelo mental completo |
Si observamos las características acumuladas de los sistemas complejos, es fácil ver por qué los métodos tradicionales de exploración de la seguridad de los sistemas son inadecuados. La salida no lineal es difícil de simular o modelar con precisión. La salida es impredecible. La gente no puede modelarlos mentalmente.
En el mundo del software, no es raro trabajar con sistemas complejos que presentan estas características. De hecho, una consecuencia de la Ley de la Variedad Necesaria 2 es que cualquier sistema de control debe tener al menos tanta complejidad como el sistema que controla. Dado que la mayor parte del software implica escribir sistemas de control, la mayor parte de la construcción de software aumenta la complejidad con el tiempo. Si trabajas en software y no trabajas con sistemas complejos hoy, es cada vez más probable que lo hagas en algún momento.
Una consecuencia del aumento de los sistemas complejos es que el papel tradicional del arquitecto de software pierde relevancia con el tiempo. En los sistemas sencillos, una persona, normalmente un ingeniero experimentado, puede orquestar el trabajo de varios ingenieros. El papel del arquitecto evolucionó porque esa persona puede modelar mentalmente todo el sistema y sabe cómo encajan todas las piezas. Puede actuar como guía y planificador de cómo se escribe la funcionalidad y cómo se desarrolla la tecnología en un proyecto de software a lo largo del tiempo.
En los sistemas complejos, reconocemos que una sola persona no puede tener todas las piezas en la cabeza. Esto significa que los ingenieros de software deben implicarse más en el diseño del sistema. Históricamente, la ingeniería es una profesión burocrática: algunas personas tienen la función de decidir qué trabajo hay que hacer, otras deciden cómo y cuándo se hará, y otras hacen el trabajo real. En los sistemas complejos, esa división del trabajo es contraproducente porque las personas que tienen más contexto son las que hacen el trabajo real. El papel de los arquitectos y la burocracia asociada pierden eficacia. Los sistemas complejos animan a las estructuras organizativas no burocráticas a construirlos, interactuar con ellos y responder a ellos de forma eficaz.
Enfrentarse a la complejidad
La naturaleza impredecible e incomprensible de los sistemas complejos plantea nuevos retos. En las secciones siguientes se ofrecen tres ejemplos de interrupciones causadas por interacciones complejas. En cada uno de estos casos, no esperaríamos que un equipo de ingeniería razonable previera de antemano la interacción indeseable.
Ejemplo 1: Desajuste entre la lógica del negocio y la lógica de la aplicación
Considera la arquitectura de microservicios descrita aquí e ilustrada en la Figura 1-1. En este sistema, tenemos cuatro componentes:
- Servicio P
- Almacena información personalizada. Un ID representa a una persona y algunos metadatos asociados a esa persona. Por simplicidad, los metadatos almacenados nunca son muy grandes, y las personas nunca se eliminan del sistema. P pasa los datos a Q para que los persista.
- Servicio Q
- Un servicio de almacenamiento genérico utilizado por varios servicios ascendentes. Almacena los datos en una base de datos persistente para la tolerancia a fallos y la recuperación, y en una base de datos caché basada en memoria para mayor velocidad.
- Servicio S
- Una base de datos de almacenamiento persistente, quizás un sistema de almacenamiento columnar como Cassandra o DynamoDB.
- Servicio T
- Una caché en memoria, quizás algo como Redis o Memcached.
Para añadir algunas fallas racionales a este sistema, los equipos responsables de cada componente prevén fallos. El Servicio Q escribirá datos en ambos servicios: S y T. Cuando recupere datos, los leerá primero del Servicio T, ya que es más rápido. Si la caché falla por algún motivo, leerá del Servicio S. Si fallan tanto el Servicio T como el Servicio S, puede enviar una respuesta por defecto para la base de datos de vuelta al servicio anterior.
Del mismo modo, el Servicio P tiene fallbacks racionales. Si Q falla o devuelve un error, P puede degradarse con elegancia devolviendo una respuesta por defecto. Por ejemplo, P podría devolver metadatos no personalizados de una persona determinada si Q falla.
Un día, T falla(Figura 1-2). Las búsquedas en P empiezan a ralentizarse, porque Q se da cuenta de que T ya no responde, y pasa a leer de S. Por desgracia para esta configuración, es habitual que los sistemas con grandes cachés tengan cargas de trabajo de lectura pesadas. En este caso, T estaba gestionando la carga de lectura bastante bien porque leer directamente de la memoria es rápido, pero S no está preparado para gestionar este aumento repentino de la carga de trabajo. S se ralentiza y acaba fallando. Esas peticiones se agotan.
Afortunadamente, Q también estaba preparado para esto, por lo que devuelve una respuesta por defecto. La respuesta por defecto de una versión concreta de Cassandra al buscar un objeto de datos cuando las tres réplicas no están disponibles es un código de respuesta 404 [No encontrado], por lo que Q emite un 404 a P.
P sabe que la persona que busca existe porque tiene un ID. Las personas nunca se eliminan del servicio. La respuesta 404 [No encontrado] que P recibe de Q es, por tanto, una condición imposible en virtud de la lógica empresarial(Figura 1-3). P podría haber gestionado un error de Q, o incluso una falta de respuesta, pero no tiene ninguna condición para captar esta respuesta imposible. P se bloquea y con él se cae todo el sistema(Figura 1-4).
¿Qué es lo que falla en este caso? Que todo el sistema se caiga es, obviamente, un comportamiento indeseable del sistema. Se trata de un sistema complejo, en el que permitimos que ninguna persona pueda tener en mente todas las piezas móviles. Cada uno de los respectivos equipos propietarios de P, Q, S y T tomó decisiones de diseño razonables. Incluso dieron un paso más para anticiparse a los fallos, detectar esos casos y degradarse con elegancia. Entonces, ¿cuál es la culpa?
Nadie tiene la culpa y ningún servicio tiene la culpa. No hay nada que reprochar. Se trata de un sistema bien construido. No sería razonable esperar que los ingenieros hubieran previsto este fallo, ya que la interacción de los componentes supera la capacidad de cualquier ser humano de tener todas las piezas en la cabeza, e inevitablemente da lugar a lagunas en las suposiciones de lo que pueden saber otros seres humanos del equipo. El resultado indeseable de este complejo sistema es un valor atípico, producido por factores contribuyentes no lineales.
Veamos otro ejemplo.
Ejemplo 2: Tormenta de reintentos inducida por el cliente
Considera el siguiente fragmento de un sistema distribuido de un servicio de streaming de películas(Figura 1-5). En este sistema, tenemos dos subsistemas principales:
- Sistema R
- Almacena una interfaz de usuario personalizada. Dado un ID que represente a una persona, devolverá una interfaz de usuario personalizada según las preferencias cinematográficas de ese individuo. R llama a S para obtener información adicional sobre cada persona.
- Sistema S
- Almacena diversa información sobre los usuarios, como si tienen una cuenta válida y lo que pueden ver. Son demasiados datos para que quepan en una instancia o máquina virtual, por lo que S separa el acceso y la lectura y escritura en dos subcomponentes:
- S-L
- Equilibrador de carga que utiliza un algoritmo hash consistente para distribuir la carga de lectura a los componentes S-D.
- S-D
- Unidad de almacenamiento que tiene una pequeña muestra del conjunto de datos completo. Por ejemplo, una instancia de S-D podría tener información sobre todos los usuarios cuyos nombres empiecen por la letra "m", mientras que otra podría almacenar aquellos cuyos nombres empiecen por la letra "p".3
El equipo que lo mantiene tiene experiencia en sistemas distribuidos y normas industriales de implementación en la nube. Esto incluye medidas como tener fallbacks racionales. Si R no puede recuperar información sobre una persona de S, entonces tiene una interfaz de usuario por defecto. Ambos sistemas también son conscientes de los costes, por lo que tienen políticas de escalado que mantienen los clusters con un tamaño adecuado. Si la E/S de disco cae por debajo de un determinado umbral en S-D, por ejemplo, S-D transferirá los datos del nodo menos ocupado y apagará ese nodo, y S-L redistribuirá esa carga de trabajo entre los nodos restantes. Los datos de S-D se guardan en una caché redundante en el nodo, de modo que si el disco va lento por algún motivo, se puede obtener un resultado ligeramente obsoleto de la caché. Las alertas se configuran para que se activen cuando aumenten los porcentajes de error, la detección de valores atípicos reiniciará las instancias que se comporten de forma extraña, etc.
Un día, un cliente al que llamaremos Louis está viendo vídeo en streaming desde este servicio en condiciones no óptimas. Concretamente, Louis está accediendo al sistema desde un navegador web en su ordenador portátil en un tren. En un momento dado, ocurre algo extraño en el vídeo y sorprende a Louis. Se le cae el portátil al suelo, se pulsan algunas teclas, y cuando vuelve a situar el portátil para seguir viendo, el vídeo se congela.
Louis hace lo que haría cualquier cliente sensato en esta situación y pulsa el botón de actualizar 100 veces. Las llamadas se ponen en cola en el navegador web, pero en ese momento el tren está entre torres de telefonía móvil, por lo que una partición de la red impide que se entreguen las solicitudes. Cuando la señal WiFi vuelve a conectarse, las 100 solicitudes se entregan a la vez.
De vuelta al lado del servidor, R recibe las 100 peticiones e inicia 100 peticiones iguales a S-L, que utiliza el hash coherente del ID de Louis para reenviar todas esas peticiones a un nodo específico de S-D que llamaremos S-D-N. Recibir 100 peticiones de una vez supone un aumento significativo, ya que S-D-N está acostumbrado a recibir una línea de base de 50 peticiones por segundo. Esto supone triplicar la línea de base, pero, afortunadamente, disponemos de fallbacks y degradaciones racionales.
La S-D-N no puede servir 150 peticiones (línea de base más Louis) en un segundo desde el disco, así que empieza a servir peticiones desde la caché. Esto es significativamente más rápido. Como resultado, tanto la E/S del disco como la utilización de la CPU disminuyen drásticamente. Llegados a este punto, las políticas de escalado entran en acción para mantener el sistema en el tamaño adecuado en función de los costes. Como la utilización del disco E/S y de la CPU es tan baja, S-D decide apagar S-D-N y transferir su carga de trabajo a un nodo par. O quizá la detección de anomalías apagó este nodo; a veces es difícil saberlo en sistemas complejos(Figura 1-6).
S-L devuelve respuestas a 99 de las peticiones de Luis, todas ellas servidas desde la caché de S-D-N, pero la respuesta número 100 se pierde debido a que la configuración del clúster cambia al apagarse S-D-N y se produce el traspaso de datos. Para esta última respuesta, como R recibe un error de tiempo de espera de S-L, devuelve una interfaz de usuario por defecto en lugar de la interfaz de usuario personalizada para Luis.
De vuelta a su portátil, el navegador de Louis ignora las 99 respuestas correctas y muestra la respuesta número 100, que es la interfaz de usuario por defecto. A Luis, esto le parece otro error, ya que no es la interfaz de usuario personalizada a la que está acostumbrado.
Luis hace lo que haría cualquier cliente sensato en esta situación y pulsa el botón de actualizar otras 100 veces. Esta vez, el proceso se repite, pero S-L reenvía las peticiones a S-D-M, que tomó el relevo de S-D-N. Por desgracia, el traspaso de datos no se ha completado, por lo que el disco de S-D-M se satura rápidamente.
S-D-M pasa a servir las peticiones desde la caché. Repitiendo el procedimiento que siguió S-D-N, esto acelera significativamente las peticiones. La E/S del disco y la utilización de la CPU caen drásticamente. Las políticas de escalado entran en acción y S-D decide cerrar S-D-M y transferir su carga de trabajo a un nodo homólogo(Figura 1-7).
S-D tiene ahora una situación de traspaso de datos en vuelo para dos nodos. Estos nodos son responsables no sólo del usuario Louis, sino de un porcentaje de todos los usuarios. R recibe más errores de tiempo de espera de S-L para este porcentaje de usuarios, por lo que R devuelve una interfaz de usuario por defecto en lugar de la interfaz de usuario personalizada para estos usuarios.
De vuelta a sus dispositivos cliente, estos usuarios tienen ahora una experiencia similar a la de Louis. A muchos de ellos les parece otro error, ya que no es la interfaz de usuario personalizada a la que están acostumbrados. Ellos también hacen lo que haría cualquier cliente sensato en esta situación y pulsan el botón de actualizar 100 veces.
Ahora tenemos una tormenta de reintentos inducida por el usuario.
El ciclo se acelera. S-D se reduce y la latencia se dispara a medida que más nodos se ven desbordados por el traspaso. S-L lucha por satisfacer las peticiones a medida que la tasa de peticiones aumenta drásticamente desde los dispositivos cliente, mientras que el tiempo de espera simultáneo mantiene abiertas durante más tiempo las peticiones enviadas a S-D. Al final, R, al mantener abiertas todas estas peticiones a S-L aunque acaben agotándose, tiene un grupo de hilos saturado que bloquea la máquina virtual. Todo el servicio se cae(Figura 1-8).
Para empeorar las cosas, la interrupción provoca más reintentos inducidos por el cliente, lo que hace aún más difícil solucionar el problema y devolver el servicio a un estado estable.
De nuevo podemos preguntarnos: ¿qué es lo que falla en este caso? ¿Qué componente se construyó incorrectamente? En un sistema complejo, ninguna persona puede tener en su mente todas las piezas móviles. Cada uno de los respectivos equipos que construyeron R, S-L y S-D tomaron decisiones de diseño razonables. Incluso dieron un paso más para anticiparse a los fallos, detectar esos casos y degradarse con elegancia. Entonces, ¿cuál es la culpa?
Como en el ejemplo anterior, aquí nadie tiene la culpa. No hay nada que reprochar. Por supuesto, con el sesgo de la retrospectiva, podemos mejorar este sistema para evitar que se repita el escenario que acabamos de describir. No obstante, no sería razonable esperar que los ingenieros hubieran previsto este fallo. Una vez más factores contribuyentes no lineales conspiraron para emitir un resultado indeseable de este complejo sistema.
Ejemplo 3: Congelación del código de vacaciones
Considera la siguiente configuración de infraestructura(Figura 1-9) para una gran empresa minorista online :
- Componente E
- un equilibrador de carga que simplemente reenvía las peticiones, similar a un equilibrador de carga elástico (ELB) en el servicio en la nube de AWS.
- Componente F
- Una pasarela API. Analiza cierta información de las cabeceras, las cookies y la ruta. Utiliza esa información para establecer un patrón con una política de enriquecimiento; por ejemplo, añadiendo cabeceras adicionales que indiquen a qué funciones está autorizado a acceder el usuario. A continuación, hace coincidir el patrón con un backend y reenvía la solicitud al backend.
- Componente G
- Una maraña de aplicaciones backend que se ejecutan con distintos niveles de criticidad, en distintas plataformas, y que cumplen innumerables funciones, para un conjunto indeterminado de usuarios.
El equipo que mantiene F tiene que gestionar algunos obstáculos interesantes. No tienen control sobre la pila ni sobre otras propiedades operativas de G. Su interfaz tiene que ser flexible para manejar muchas formas diferentes de patrones para hacer coincidir las cabeceras de las peticiones, las cookies y las rutas y entregar las peticiones en el lugar correcto. El perfil de rendimiento de G abarca todo el espectro, desde respuestas de baja latencia con pequeñas cargas útiles hasta conexiones "keep-alive" que transmiten grandes archivos. No se puede planificar ninguno de estos factores porque los componentes de G y más allá son en sí mismos sistemas complejos con propiedades que cambian dinámicamente.
F es muy flexible y gestiona un conjunto diverso de cargas de trabajo. Se añaden nuevas funciones a F aproximadamente una vez al día y se implementan para satisfacer nuevos casos de uso de G. Para aprovisionar un componente tan funcional, el equipo escala verticalmente la solución a lo largo del tiempo para adaptarse al aumento de los casos de uso de G. Las cajas cada vez más grandes les permiten asignar más memoria, lo que lleva tiempo iniciar. Más y más coincidencia de patrones, tanto para el enriquecimiento como para el enrutamiento, da como resultado un conjunto de reglas considerable, que se prepara en una máquina de estados y se carga en memoria para un acceso más rápido. Esto también lleva tiempo. Cuando todo está dicho y hecho, cada una de estas grandes máquinas virtuales que ejecutan F tarda unos 40 minutos en aprovisionarse, desde el momento en que se pone en marcha el canal de aprovisionamiento hasta que todas las cachés están calientes y la instancia funciona con un rendimiento básico o próximo a él.
Como F está en la ruta crítica de todos los accesos a G, el equipo que lo opera entiende que es un potencial punto único de fallo. No sólo despliegan una instancia; despliegan un clúster. El número de instancias en un momento dado se determina de modo que todo el clúster tenga una capacidad adicional del 50%. En un momento dado, un tercio de las instancias podría desaparecer de repente y todo seguiría funcionando.
Escalado verticalmente, escalado horizontalmente y sobreaprovisionado: F es un componente caro .
Para ir más allá en cuanto a disponibilidad, el equipo toma varias precauciones adicionales. La canalización CI ejecuta un conjunto exhaustivo de pruebas unitarias y de integración antes de crear una imagen para la máquina virtual. Los canarios automatizados prueban cualquier nuevo cambio de código en una pequeña cantidad de tráfico antes de proceder a un modelo de implementación azul/verde que ejecuta una buena parte del clúster en paralelo antes de pasar completamente a una nueva versión. Todas las pull requests para cambiar el código a F se someten a una política de dos revisores y el revisor no puede ser alguien que trabaje en la función que se está cambiando, lo que requiere que todo el equipo esté bien informado sobre todos los aspectos del desarrollo en marcha.
Por último, toda la organización entra en una congelación de código a principios de noviembre hasta enero. No se permite ningún cambio durante este tiempo a menos que sea absolutamente crítico para la seguridad del sistema, ya que las vacaciones entre el Viernes Negro y el Día de Año Nuevo son las temporadas de mayor tráfico para la empresa. La posibilidad de introducir un error por una característica no crítica podría ser catastrófica en este periodo, así que la mejor forma de evitar esa posibilidad es no cambiar el sistema en absoluto. Como mucha gente se va de vacaciones en esta época del año, también es conveniente restringir la implementación del código desde el punto de vista de la supervisión.
Entonces, un año se produce un fenómeno interesante. A finales de la segunda semana de noviembre, dos semanas después de la congelación del código, se avisa al equipo de un aumento repentino de errores en una instancia. No hay problema: se cierra esa instancia y se arranca otra. En el transcurso de los 40 minutos siguientes, antes de que la nueva instancia esté plenamente operativa, otras máquinas también experimentan un aumento similar de errores. A medida que se arrancan nuevas instancias para sustituirlas, el resto del clúster experimenta el mismo fenómeno.
En el transcurso de varias horas, todo el clúster se sustituye por nuevas instancias que ejecutan exactamente el mismo código. Incluso con una sobrecarga del 50%, un número significativo de solicitudes quedan sin servicio durante el periodo en que todo el clúster se reinicia en un intervalo tan corto. Esta interrupción parcial fluctúa en gravedad durante horas antes de que finalice todo el proceso de aprovisionamiento y se estabilice el nuevo clúster.
El equipo se enfrenta a un dilema: para solucionar el problema, tendrían que desplegar una nueva versión con medidas de observabilidad centradas en una nueva área de código. Pero la congelación del código está en pleno apogeo, y el nuevo clúster parece, según todas las métricas, estable. La semana siguiente deciden desplegar un pequeño número de nuevas instancias con las nuevas medidas de observabilidad.
Pasan dos semanas sin incidentes cuando, de repente, vuelve a producirse el mismo fenómeno. Primero unas pocas instancias, y luego todas, experimentan un aumento repentino de la tasa de errores. Es decir, todas las instancias excepto las que se instrumentaron con las nuevas medidas de observabilidad.
Al igual que en el incidente anterior, todo el cluster se reinicia durante varias horas y aparentemente se estabiliza. Esta vez la interrupción es más grave, ya que la empresa se encuentra en su temporada de mayor actividad.
Unos días después, las instancias instrumentadas con nuevas medidas de observabilidad empiezan a ver el mismo pico de errores. Gracias a las métricas recopiladas, se descubre que una biblioteca importada provoca una fuga de memoria predecible que escala linealmente con el número de peticiones atendidas. Como las instancias son tan masivas, la fuga tarda aproximadamente dos semanas en consumir suficiente memoria como para causar una privación de recursos suficiente para afectar a otras bibliotecas.
Este fallo se había introducido en el código base casi nueve meses antes. El fenómeno nunca se había visto antes porque ninguna instancia del clúster había funcionado nunca más de cuatro días. Las nuevas funciones provocaban una nueva implementación de código, que se repetía cíclicamente en nuevas instancias. Irónicamente, fue un procedimiento destinado a aumentar la seguridad -la congelación del código en vacaciones- lo que provocó que el fallo se manifestara en una interrupción.
De nuevo nos preguntamos: ¿cuál es el fallo en este caso? Podemos identificar el fallo en la biblioteca dependiente, pero no aprendemos nada si señalamos como culpable a un codificador externo que ni siquiera tiene conocimiento de este proyecto. Todos los miembros del equipo que trabajaron en F tomaron decisiones de diseño razonables. Incluso dieron un paso más para anticiparse a los fallos, escalonar la implementación de nuevas funciones, sobreaprovisionar y "tener cuidado" todo lo que se les ocurrió hacer. Entonces, ¿de quién es la culpa?
Como en los dos ejemplos anteriores, aquí nadie tiene la culpa. No hay nada que reprochar. No sería razonable esperar que los ingenieros hubieran previsto este fallo. Los factores contribuyentes no lineales de produjeron un resultado indeseable y costoso en este complejo sistema.
Afrontar la complejidad
Los tres ejemplos anteriores ilustran casos en los que no se podía esperar razonablemente que ninguno de los humanos del bucle previera las interacciones que finalmente condujeron al resultado indeseable. Los humanos seguirán escribiendo software en un futuro previsible, por lo que sacarlos del bucle no es una opción. ¿Qué se puede hacer entonces para reducir fallos sistémicos como éstos?
Una idea popular de es reducir o eliminar la complejidad. Si eliminamos la complejidad de un sistema complejo, ya no tendremos los problemas de los sistemas complejos.
Quizá si pudiéramos reducir estos sistemas a otros más sencillos y lineales, podríamos incluso identificar a los culpables cuando algo sale mal. En este hipotético mundo más sencillo, podemos imaginar que un gestor hipereficiente e impersonal podría eliminar todos los errores simplemente deshaciéndose de las manzanas podridas que crean esos errores.
Para examinar esta posible solución, es útil comprender algunas características adicionales de la complejidad. A grandes rasgos, la complejidad puede clasificarse en dos categorías: accidental y esencial, una distinción realizada por Frederick Brooks en la década de 1980.4
Complejidad accidental
La complejidad accidental de es una consecuencia de escribir software en un entorno de recursos limitados, es decir, este universo. En el trabajo diario siempre hay prioridades que compiten entre sí. Para los ingenieros de software, las prioridades explícitas pueden ser la velocidad de las características, la cobertura de las pruebas y la idiomaticidad. Las prioridades implícitas pueden ser la economía, la carga de trabajo y la seguridad. Nadie dispone de tiempo y recursos infinitos, por lo que la navegación por estas prioridades desemboca inevitablemente en un compromiso.
El código que escribimos está impregnado de nuestras intenciones, suposiciones y prioridades en un momento determinado. No puede ser correcto porque el mundo cambiará, y lo que esperamos de nuestro software cambiará con él.
Un compromiso en software puede manifestarse como un fragmento de código ligeramente subóptimo, una intención vaga detrás de un contrato, un nombre de variable equívoco, un énfasis en una ruta de código abandonada más tarde, etc. Como la suciedad en el suelo, estos fragmentos se acumulan. Nadie trae suciedad a una casa y la pone en el suelo a propósito; simplemente ocurre como un subproducto de la vida. Del mismo modo, el código subóptimo es un subproducto de la ingeniería. En algún momento, estos subóptimos acumulados superan la capacidad de una persona para comprenderlos intuitivamente, y en ese momento tenemos complejidad, concretamente, complejidad accidental.
Lo interesante de la complejidad accidental es que no existe ningún método conocido y sostenible para reducirla. Puedes reducir la complejidad accidental en un momento dado dejando de trabajar en nuevas funciones para reducir la complejidad del software escrito anteriormente. Esto puede funcionar, pero hay advertencias.
Por ejemplo, no hay razón para suponer que los compromisos que se hicieron en el momento en que se escribió el código estaban menos informados que los que se harán en una refactorización. El mundo cambia, al igual que nuestras expectativas sobre cómo debe comportarse el software. A menudo ocurre que escribir nuevo software para reducir la complejidad accidental simplemente crea nuevas formas de complejidad accidental. Estas nuevas formas pueden ser más aceptables que las anteriores, pero esa aceptabilidad caducará aproximadamente al mismo ritmo.
Las grandes refactorizaciones a menudo sufren lo que se conoce como el efecto del segundo sistema, un término también introducido por Frederick Brooks, en el que se supone que el proyecto posterior es mejor que el original gracias a la visión adquirida durante el desarrollo del primero. En cambio, estos segundos sistemas acaban siendo más grandes y complejos debido a compensaciones involuntarias inspiradas por el éxito de escribir la primera versión.
Independientemente del enfoque adoptado para reducir la complejidad accidental, ninguno de estos métodos es sostenible. Todos requieren desviar recursos limitados, como tiempo y atención, del desarrollo de nuevas funciones. En cualquier organización en la que la intención sea progresar, estas desviaciones entran en conflicto con otras prioridades. Por tanto, no son sostenibles.
Así que, como subproducto de escribir código, siempre se acumula complejidad accidental.
Complejidad esencial
Si no podemos reducir de forma sostenible la complejidad accidental, entonces quizás podamos reducir el otro tipo de complejidad. La complejidad esencial en el software es el código que escribimos que añade intencionadamente más sobrecarga porque ese es el trabajo. Como ingenieros de software, escribimos nuevas funciones, y las nuevas funciones hacen las cosas más complejas.
Considera el siguiente ejemplo: tienes la base de datos más sencilla que puedas imaginar. Es un almacén de datos clave/valor, como se ve en la Figura 1-10: dale una clave y un valor, y almacenará el valor. Dale una clave y devolverá el valor. Para hacerlo absurdamente sencillo, imagina que se ejecuta en memoria en tu ordenador portátil.
Ahora imagina que se te encomienda la tarea de hacerlo más disponible. Podemos ponerlo en la nube. Así, cuando cerremos la tapa del portátil, los datos persistirán. Podemos añadir varios nodos para que haya redundancia. Podemos poner el espacio de claves detrás de un hash consistente y distribuir los datos a múltiples nodos. Podemos transferir los datos de esos nodos al disco, de modo que podamos conectarlos y desconectarlos para repararlos o transferir los datos. Podemos replicar un clúster a otro en diferentes regiones, de modo que si una región o centro de datos deja de estar disponible, podamos seguir accediendo al otro clúster.
En un párrafo podemos describir rápidamente un montón de principios de diseño bien conocidos para hacer que una base de datos esté más disponible.
Volvamos ahora a nuestro sencillo almacén de datos clave/valor que se ejecuta en memoria en nuestro portátil(Figura 1-11). Imagina que se te encomienda la tarea de hacerlo más disponible, y más sencillo, simultáneamente. No dediques demasiado tiempo a intentar resolver este enigma: no puede hacerse de ninguna manera significativa.
Añadir nuevas funciones al software (o propiedades de seguridad como la disponibilidad y la seguridad) requiere añadir complejidad.
En conjunto,, la perspectiva de cambiar nuestros sistemas complejos por sistemas simples no es alentadora. La complejidad accidental siempre se acumulará como subproducto del trabajo, y la complejidad esencial será impulsada por las nuevas funciones. Para progresar en software, la complejidad aumentará.
Aceptar la complejidad
Si la complejidad de está provocando malos resultados, y no podemos eliminar la complejidad, ¿qué debemos hacer? La solución es un proceso de dos pasos.
El primer paso es aceptar la complejidad en lugar de evitarla. La mayoría de las propiedades que deseamos y para las que optimizamos nuestro software requieren añadir complejidad. Intentar optimizar la simplicidad establece una prioridad errónea y generalmente conduce a la frustración. Ante la inevitable complejidad, a veces oímos: "No añadas complejidad innecesaria". Claro, pero lo mismo podría decirse de cualquier cosa: "No añadas ningún _____ innecesario". Acepta que la complejidad va a aumentar, aunque el software mejore, y eso no es malo.
El segundo paso, que es el tema del Capítulo 2, es aprender a navegar por la complejidad. Encuentra herramientas para moverte rápidamente con confianza. Aprende prácticas para añadir nuevas funciones sin exponer tu sistema a mayores riesgos de comportamientos no deseados. En lugar de hundirte en la complejidad y ahogarte en la frustración, surféala como una ola. Como ingeniero, la Ingeniería del Caos puede ser la forma más accesible y eficaz de empezar a navegar por la complejidad de tu sistema.
1 Véase Peter Senge, La Quinta Disciplina (Nueva York, NY: Doubleday, 2006).
2 Véase el comentario sobre la "Ley de la variedad necesaria" de W. Ross Ashby, en W. Ross Ashby, "La variedad necesaria y sus implicaciones para el control de sistemas complejos", Cybernetica 1:2 (1958), pp. 83-99. Simplificando demasiado, un sistema A que controle totalmente al sistema B tiene que ser al menos tan complejo como el sistema B.
3 No funciona exactamente así, porque el algoritmo hash consistente distribuye los objetos de datos de forma pseudoaleatoria en todas las instancias S-D.
4 Frederick Brooks, "No Silver Bullet-Essence and Accident in Software Engineering", de Proceedings of the IFIP Tenth World Computing Conference, H.-J. Kugler ed., Elsevier Science BV, Amsterdam (1986).
Get Ingeniería del caos now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.