En esta segunda entrega de "Inside the Java SDK" vamos a ver en profundidad cómo el SDK gestiona y agrupa los sockets a los distintos nodos y servicios. Aunque en última instancia no es necesario seguir, te recomiendo que eches un vistazo al primer post sobre arranque también.
Tenga en cuenta que este post fue escrito con las versiones Java SDK 2.5.9 / 2.6.0 en mente. Las cosas podrían cambiar con el tiempo, pero el enfoque general debería seguir siendo el mismo.
Siguiendo el espíritu de los modelos OSI y TCP, propongo un modelo de tres capas que represente la pila de conexiones del SDK:
1 2 3 4 5 6 7 |
+-----------------+ | Servicio Capa | +-----------------+ | Punto final Capa | +-----------------+ | Canal Capa | +-----------------+ |
Los niveles superiores se superponen a los inferiores, por lo que empezaremos por la capa Canal e iremos subiendo por la pila.
La capa de canales
La capa de canal es el nivel más bajo en el que el SDK se ocupa de las redes y está construida sobre la excelente biblioteca IO totalmente asíncrona llamada Netty Hemos sido usuarios extensivos de Netty durante años y también hemos contribuido con parches así como con el códec memcache al proyecto.
Cada Netty Canal corresponde a un socket y se multiplexa sobre bucles de eventos. Cubriremos el modelo de hilos en una entrada posterior del blog, pero por ahora es importante saber que en lugar del modelo de "un hilo por socket" del IO de bloqueo tradicional, Netty toma todos los sockets abiertos y los distribuye a través de un puñado de bucles de eventos. Hace esto de una manera muy eficiente, por lo que no es de extrañar que Netty se utiliza en todo el sector para componentes de red de alto rendimiento y baja latencia.
Dado que un canal sólo se ocupa de los bytes que entran y salen, necesitamos una forma de codificar y decodificar las solicitudes a nivel de aplicación (como una consulta N1QL o una solicitud de obtención de clave/valor) en su representación binaria adecuada. En Netty esto se hace agregando manipuladores a la canalización. Todas las operaciones de escritura de la red bajan por la tubería y las respuestas del servidor vuelven a subir por la tubería (también llamadas de entrada y salida en la terminología de Netty).
Algunos manejadores se añaden independientemente del servicio utilizado (como el registro o el cifrado) y otros dependen del tipo de servicio (por ejemplo, para una respuesta N1QL tenemos analizadores de flujo JSON personalizados para la estructura de la respuesta).
Si alguna vez te has preguntado cómo obtener una salida de registro a nivel de paquetes durante el desarrollo o depuración (para producción usa tcpdump, wireshark o similar), todo lo que necesitas hacer es activar el nivel de registro TRACE en tu biblioteca de registro favorita y verás una salida como esta:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[cb-io-1-1] 2018-06-28 14:03:34 TRACE LoggingHandler:94 - [id: 0x41407638, L:/127.0.0.1:60923 - R:localhost/127.0.0.1:11210] ESCRIBIR: 243B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 80 1f 00 db 00 00 00 00 00 00 00 e5 00 00 00 00 |................| |00000010| 00 00 00 00 00 00 00 00 7b 22 61 22 3a 22 63 6f |........{"a":"co| |00000020| 75 63 68 62 61 73 65 2d 6a 61 76 61 2d 63 6c 69 |uchbase-java-cli| |00000030| 65 6e 74 2f 32 2e 36 2e 30 2d 53 4e 41 50 53 48 |ent/2.6.0-SNAPSH| |00000040| 4f 54 20 28 67 69 74 3a 20 32 2e 36 2e 30 2d 62 |OT (git: 2.6.0-b| |00000050| 65 74 61 2d 31 36 2d 67 35 63 65 30 38 62 30 2c |eta-16-g5ce08b0,|| |00000060| 20 63 6f 72 65 3a 20 31 2e 36 2e 30 2d 62 65 74 | core: 1.6.0-bet| |00000070| 61 2d 33 33 2d 67 31 62 33 65 36 66 62 29 20 28 |a-33-g1b3e6fb) (|00000070 |00000080| 4d 61 63 20 4f 53 20 58 2f 31 30 2e 31 33 2e 34 |Mac OS X/10.13.4| |00000090| 20 78 38 36 5f 36 34 3b 20 4a 61 76 61 20 48 6f | x86_64; Java Ho|| |000000a0| 74 53 70 6f 74 28 54 4d 29 20 36 34 2d 42 69 74 |tSpot(TM) 64-Bit| |000000b0| 20 53 65 72 76 65 72 20 56 4d 20 31 2e 38 2e 30 | Server VM 1.8.0| |000000c0| 5f 31 30 31 2d 62 31 33 29 22 2c 22 69 22 3a 22 |_101-b13)","i":"| |000000d0| 30 43 34 37 35 41 43 41 35 46 33 38 30 41 32 31 |0C475ACA5F380A21| |000000e0| 2f 30 30 30 30 30 30 30 34 31 34 30 37 36 33 |/000000004140763| |000000f0| 38 22 7d |8"} | +--------+-------------------------------------------------+----------------+ |
Observe el pequeño LoggingHandler ahí arriba? Esto se debe a que sólo añadimos el controlador de registro si el rastreo está habilitado para la tubería, por lo que no está pagando la sobrecarga si no lo está utilizando (que es la mayor parte del tiempo):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
arranque = nuevo BootstrapAdapter(nuevo Bootstrap() // *snip* .opción(CanalOpción.ASIGNADOR, asignador) .opción(CanalOpción.TCP_NODELAY, tcpNodelay) .opción(CanalOpción.CONNECT_TIMEOUT_MILLIS, env.socketConnectTimeout()) .manipulador(nuevo CanalInicializador<Channel>() { @Override protegido void initChannel(Canal canal) lanza Excepción { CanalPipeline tubería = canal.tubería(); si (env.sslEnabled()) { tubería.addLast(nuevo SslHandler(sslEngineFactory.consiga())); } si (LOGGER.isTraceEnabled()) { tubería.addLast(LOGGING_HANDLER_INSTANCE); } customEndpointHandlers(tubería); } })); |
También puedes ver que dependiendo de la configuración del entorno hacemos otros ajustes como añadir un handler SSL/TLS al pipeline o configurar el nodelay TCP y los timeouts de los sockets.
En customEndpointHandlers se sobrescribe para cada servicio, aquí está la tubería para la capa KV (ligeramente simplificada):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
si (medio ambiente().keepAliveInterval() > 0) { tubería.addLast(nuevo IdleStateHandler(medio ambiente().keepAliveInterval(), 0, 0, Unidad de tiempo.MILLISEGUNDOS)); } tubería .addLast(nuevo BinaryMemcacheClientCodec()) .addLast(nuevo BinaryMemcacheObjectAggregator(Entero.VALOR_MAX)); tubería .addLast(nuevo KeyValueFeatureHandler(contexto())) .addLast(nuevo KeyValueErrorMapHandler()); si (!medio ambiente().certAuthEnabled()) { tubería.addLast(nuevo KeyValueAuthHandler(nombre de usuario(), contraseña(), medio ambiente().forceSaslPlain())); } tubería .addLast(nuevo KeyValueSelectBucketHandler(cubo())) .addLast(nuevo KeyValueHandler(este, responseBuffer(), falso, verdadero)); |
Aquí pasan muchas cosas. Vayamos por partes:
- En IdleStateHandler se utiliza para activar los keepalives a nivel de aplicación.
- Los dos gestores siguientes BinaryMemcacheClientCodec y BinaryMemcacheObjectAggregator se encargan de codificar los objetos de solicitud y respuesta de memcache en sus representaciones en bytes y viceversa.
- KeyValueFeatureHandler , KeyValueErrorMapHandler , KeyValueAuthHandler y KeyValueSelectBucketHandler todos realizan el handshaking, la autenticación, la selección de cubos, etc. durante la fase de conexión y se retiran de la cadena una vez completada.
- Por último, el KeyValueHandler realiza la mayor parte del trabajo y "conoce" todos los tipos de solicitudes que entran y salen del sistema.
Si quieres echar un vistazo a uno diferente, aquí es el oleoducto N1QL, por ejemplo.
Antes de subir una capa hay algo importante. El observable RxJava finalización también ocurre en esta capa. Una vez que se decodifica una respuesta, se completa en el bucle de eventos directamente o en un grupo de hilos (configurado por defecto).
Es importante saber que una vez que un canal se cae (porque el socket subyacente se cierra) todo el estado a este nivel desaparece. En un intento de reconexión se crea un nuevo canal. ¿Quién gestiona un canal? Subamos un nivel.
La capa final
En Punto final se encarga de gestionar el ciclo de vida de un canal, incluyendo el arranque, la reconexión y la desconexión. Puede encontrar el código aquí.
Siempre hay una relación 1:1 entre el Endpoint y el canal que gestiona, pero si un canal desaparece y hay que reconectar un socket, el endpoint sigue siendo el mismo y recibe uno nuevo internamente. El endpoint es también el lugar donde se entrega la petición a los bucles de eventos (simplificado):
1 2 3 4 5 6 7 8 |
@Override público void enviar(final CouchbaseRequest solicitar) { si (canal.isActive() && canal.isWritable()) { canal.escriba a(solicitar, canal.voidPromise()); } si no { responseBuffer.publicarEvento(ResponseHandler.TRADUCTOR_RESPUESTA, solicitar, solicitar.observable()); } } |
Si nuestro canal está activo y se puede escribir en él, escribiremos la solicitud en el canal, de lo contrario, se devuelve y se vuelve a poner en cola para otro intento.
Aquí hay un aspecto muy importante del endpoint a tener en cuenta: si un canal se cierra, el endpoint intentará reconectarse (con el backoff configurado) mientras se le diga explícitamente que se detenga. Se detiene cuando el gestor del Punto final llama a desconectar que ocurrirá en última instancia cuando el servicio/nodo respectivo ya no forme parte de la configuración. Así que al final de un rebalanceo o durante un failover el cliente recibirá una nueva configuración de cluster de la cual infiere que este endpoint puede ser terminado y entonces lo hace en consecuencia. Si, por cualquier razón, hay un retraso entre la desconexión de un socket y la propagación de esta información, es posible que se produzcan algunos intentos de reconexión que se detendrán finalmente.
Un punto final está muy bien, pero más siempre es mejor, ¿verdad? Así que subamos una capa más para averiguar cómo se agrupan los puntos finales para crear sofisticados grupos de conexiones por nodo y servicio.
La capa de servicios
En Servicio gestiona uno o más endpoints por nodo. Cada servicio sólo es responsable de un nodo - así, por ejemplo, si tienes un cluster Couchbase de 5 nodos con sólo el servicio KV activado en cada uno de ellos, si inspeccionas un volcado de heap encontrarás 5 instancias del servicio KeyValueService .
En versiones anteriores del cliente sólo se podía configurar un número fijo de puntos finales por servicio mediante métodos como kvEndpoints , queryEndpoints etc. Debido a requisitos más complejos, hemos obviado este enfoque "fijo" con una potente implementación de pool de conexiones. Esta es la razón por la que en lugar de queryEndpoints ahora debe utilizar queryServiceConfig y equivalentes.
Estos son los grupos por defecto por servicio en 2.5.9 y 2.6.0:
- KeyValueService : 1 punto final por nodo, fijo.
- Servicio de consulta de 0 a 12 puntos finales por nodo, dinámico.
- VerServicio de 0 a 12 puntos finales por nodo, dinámico.
- AnálisisServicio de 0 a 12 puntos finales por nodo, dinámico.
- Servicio de búsqueda de 0 a 12 puntos finales por nodo, dinámico.
La razón por la que la KV no se agrupa de forma predeterminada es que el establecimiento de conexiones es mucho más costoso (recuerde todos los gestores de la canalización) y el patrón de tráfico suele ser muy diferente del de los servicios basados en consultas más pesadas. La experiencia sobre el terreno ha demostrado que aumentar el número de puntos finales de KV sólo tiene sentido en escenarios de "carga masiva" y tráfico muy irregular en los que la "tubería" de un socket es demasiado pequeña. Si esto no se evalúa adecuadamente, también podría ser que añadir más sockets a la capa KV puede degradar el rendimiento en lugar de mejorarlo - supongo que más no siempre es mejor.
La lógica de puesta en común se encuentra en aquí si tienes curiosidad, pero merece la pena examinar cierta semántica que hay ahí.
Durante la fase de conexión del servicio, se asegura de que se establece por adelantado el número mínimo de puntos finales. Si el mínimo es igual al máximo, se desactiva la agrupación dinámica y el código elegirá uno de los puntos finales para cada solicitud:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
sincronizado (epMutex) { int numToConnect = minEndpoints - puntos finales.talla(); si (numToConnect == 0) { LOGGER.depurar("No se necesitan puntos finales para conectar, saltando".); devolver Observable.sólo(estado()); } para (int i = 0; i < numToConnect; i++) { Punto final punto final = endpointFactory.crear(nombre de host, cubo, nombre de usuario, contraseña, puerto, ctx); puntos finales.añada(punto final); endpointStates.regístrese en(punto final, punto final); } LOGGER.depurar(logIdent(nombre de host, PooledService.este) + "El nuevo número de puntos finales es {}", puntos finales.talla()); } |
Esto puede observarse en los registros inmediatamente durante el arranque:
1 2 |
[cb-cálculos-5] 2018-06-28 14:03:34 DEBUG Servicio:257 - [localhost][KeyValueService]: Nuevo número de puntos finales es 1 [cb-cálculos-8] 2018-06-28 14:03:35 DEBUG Servicio:248 - [localhost][Servicio de consulta]: No puntos finales necesario a conecte, saltarse. |
Cuando llega una solicitud, o bien se envía, o bien se crea otro endpoint (si aún queda espacio en el pool), que también se gestiona (de forma ligeramente simplificada):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@Override público void enviar(final CouchbaseRequest solicitar) { Punto final punto final = puntos finales.talla() > 0 ? selectionStrategy.seleccione(solicitar, puntos finales) : null; si (punto final == null) { si (fixedEndpoints || (puntos finales.talla() >= maxEndpoints)) { RetryHelper.retryOrCancel(env, solicitar, responseBuffer); } si no { maybeOpenAndSend(solicitar); } } si no { punto final.enviar(solicitar); } } |
Tenga en cuenta que si no podemos encontrar un endpoint adecuado y el pool es fijo o hemos alcanzado nuestro techo entonces la operación se programa para reintento, muy similar a la lógica del endpoint cuando no está activo o no se puede escribir.
En los servicios basados en pool HTTP no queremos mantener esos sockets para siempre, así que puedes configurar un tiempo de inactividad (que es de 300s por defecto). Cada pool ejecuta un temporizador de inactividad que examina regularmente los endpoints por si han estado inactivos durante más tiempo que el intervalo configurado y si es así lo desconecta. Nótese que la lógica siempre se asegura de no caer por debajo del número mínimo.
Errores comunes relacionados con la conexión
Ahora que tienes una buena idea de cómo el SDK maneja los sockets y los agrupa, vamos a hablar de un par de escenarios de error que pueden surgir.
Solicitud de anulación
Hablemos del RequestCancelledException primero.
Si está realizando una operación y falla con un RequestCancelledException suele haber dos causas diferentes:
- La operación dio vueltas dentro del cliente (sin enviarse por la red) durante más tiempo del configurado maxRequestLifetime .
- Se ha escrito una petición a la red, pero antes de obtener una respuesta se ha cerrado el canal subyacente.
Hay otras razones menos comunes (por ejemplo, problemas durante la codificación de una solicitud), pero para el propósito de este blog nos centraremos en la segunda causa.
Entonces, ¿por qué tenemos que cancelar la petición y no reintentarla en otro socket que todavía esté activo? La razón es que no sabemos si la operación ya ha causado un efecto secundario en el servidor (por ejemplo una mutación aplicada). Si reintentáramos operaciones no idempotentes se producirían efectos extraños difíciles de diagnosticar en la práctica. En lugar de eso, le decimos a quien llama que la petición ha fallado y entonces depende de la lógica de la aplicación averiguar qué hacer a continuación. Si era una simple petición get y todavía estás en tu presupuesto de tiempo de espera puedes reintentarlo por tu cuenta. Si se trata de una mutación que usted necesita para poner un poco más de lógica en su lugar para leer el documento y averiguar si se ha aplicado o usted sabe que puede ser enviado de nuevo de inmediato. Y luego siempre está la opción de propagar el error de nuevo a la persona que llama a su API. En cualquier caso es predecible desde el lado del SDK y no causará más daño en segundo plano.
Problemas de Bootstrap
La otra fuente de errores que vale la pena conocer son los problemas durante la fase de conexión del socket. Normalmente encontrarás errores descriptivos en los registros que te dirán lo que está pasando (por ejemplo credenciales erróneas) pero hay dos que pueden ser un poco más difíciles de descifrar: El tiempo de espera de la salvaguarda de conexión y los errores de selección de cubo durante el reequilibrio.
Como has visto antes, el pipeline de KV contiene muchos handlers que trabajan con el servidor durante el bootstrap para averiguar todo tipo de configuraciones y negociar las características soportadas. En el momento de escribir esto, cada operación individual no tiene un tiempo de espera individual, sino que el tiempo de espera de salvaguarda de la conexión se activa si tarda más de lo que la fase de conexión tiene permitido en términos de presupuesto total.
Así que si ves el ConnectTimeoutException en los registros con el mensaje Conectar devolución de llamada hizo no devolver, hit salvaguardia tiempo de espera. lo que significa es que una operación o la suma de todas ellas ha tardado más de lo presupuestado y se realizará otro intento de reconexión. Esto no es perjudicial en general, ya que nos volveremos a conectar, pero es un buen indicio de que puede haber alguna lentitud en la red o en algún otro lugar de la pila que debería examinarse con más detenimiento. Un buen siguiente paso sería iniciar wireshark / tcpdump y registrar las fases de arranque para averiguar dónde se gasta el tiempo y luego pivotar hacia el lado del cliente o del servidor en función de los tiempos registrados. Por defecto, el tiempo de espera de salvaguardia está configurado como el socketConnectTimeout más el connectCallbackGracePeriod que está ajustado a 2 segundos y puede sintonizarse a través de la tecla com.couchbase.connectCallbackGracePeriod propiedad del sistema.
Uno de los pasos durante el bootstrap desde que añadimos soporte para RBAC (control de acceso basado en roles) se llama "select bucket" a través de la función KeyValueSelectBucketHandler . Dado que existe una desconexión entre la autenticación y el acceso a un bucket, es posible que el cliente se conecte a un servicio de KV pero el propio motor de KV no esté aún preparado para servirlo. El cliente manejará la situación con elegancia y volverá a intentarlo -y no se observa ningún impacto en la carga de trabajo real-, pero dado que la higiene de los registros también es una preocupación, actualmente estamos mejorando el algoritmo del SDK en este punto. Si quieres puedes seguir el progreso en JVMCBC-553.
Reflexiones finales
A estas alturas deberías tener una sólida comprensión de cómo el SDK gestiona sus sockets subyacentes y los agrupa en la capa de servicio. Si quieres profundizar en el código base, empieza por aquí y luego buscar en los respectivos espacios de nombres para servicio y punto final . Todos los manejadores de canal de Netty están por debajo del punto final también.
Si tienes más preguntas, ¡comenta a continuación! El próximo post tratará sobre el modelo general de roscado del SDK.