Esta guía es para quien ya trabaja con backend y necesita volver a conversar o implementar servicios Go de producción. Las decisiones que más importan son concurrencia limitada, cancelación, colas finitas, idempotencia y observabilidad.
El recorte usa Go 1.26. No es una introducción al lenguaje. Repasa la sintaxis rápido y dedica el tiempo a las decisiones que cambian el diseño de un consumer, una API o un pipeline de telemetría.
Ruta de 30 minutos
| Tiempo | Bloque | Prioridad |
|---|---|---|
| 0-4 min | Tipos, structs, interfaces y errores | Revisión rápida |
| 4-10 min | Goroutines, channels, select y contexto | Alta |
| 10-17 min | Concurrencia limitada y backpressure | Máxima |
| 17-23 min | Pipeline IoT resiliente | Máxima |
| 23-26 min | Runtime, memoria y profiling | Alta |
| 26-30 min | Arquitectura y entrevista | Máxima |
Fundamentos que aparecen en producción
Go favorece composición, contratos pequeños y flujo explícito. Un valor puede llevar su propia validación sin depender de un framework:
var ErrOutOfRange = errors.New("reading out of range")
type Reading struct {
DeviceID string
Sequence uint64
Value float64
}
func (r Reading) Validate() error {
if r.DeviceID == "" {
return errors.New("device_id is required")
}
if r.Value < -100 || r.Value > 250 {
return fmt.Errorf("%w: %.2f", ErrOutOfRange, r.Value)
}
return nil
}
El zero value suele ser útil. Los slices comparten backing array hasta que append realoca; los maps no tienen orden de iteración y necesitan sincronización para acceso concurrente. Un string contiene bytes inmutables, generalmente UTF-8. defer se ejecuta en orden LIFO, pero evalúa sus argumentos al registrarse.
Las interfaces se satisfacen implícitamente. Define interfaces pequeñas donde se consumen. Los errores son valores: añade contexto con %w e inspecciona la cadena con errors.Is o errors.As. Reserva panic para invariantes rotas o fallos irrecuperables de arranque.
Goroutines, channels y contexto
Una goroutine no es un hilo dedicado. El runtime la planifica sobre hilos del sistema operativo. Cada goroutine necesita owner, condición de término y alguien que espere su finalización.
Los channels transfieren trabajo u ownership. Los mutexes protegen estado compartido. Un channel con buffer suaviza una diferencia temporal de velocidad; no crea capacidad infinita.
func enqueue(ctx context.Context, jobs chan<- Reading, reading Reading) error {
select {
case jobs <- reading:
return nil
case <-ctx.Done():
return context.Cause(ctx)
}
}
El productor cierra un channel cuando sabe que no habrá más envíos. Enviar a un channel cerrado o cerrarlo dos veces causa panic. Un channel nil bloquea para siempre y deshabilita su caso dentro de select.
context.Context propaga cancelación, deadlines y metadatos de la request. Recíbelo primero, propágalo, llama a cada cancel retornado y no lo guardes en una struct. La cancelación es cooperativa: los loops bloqueantes deben observar ctx.Done().
Limita la concurrencia antes de que la memoria sea el límite
Una goroutine por mensaje se vuelve costosa cuando un downstream se ralentiza. Crecen las colas y el heap, el GC trabaja más y el proceso puede caer antes de que la CPU parezca llena.
errgroup combina espera, propagación del primer error y cancelación compartida. Pon un límite para tareas independientes:
func ProcessBatch(ctx context.Context, batch []Reading) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(16)
for _, reading := range batch {
reading := reading
g.Go(func() error {
return processOne(ctx, reading)
})
}
return g.Wait()
}
Clasifica los errores antes de actuar. Una base indisponible puede cancelar un lote. Un payload inválido, duplicado o incompatible con el schema debe ir a cuarentena o DLQ, sin detener el consumer completo.
Usa atomic para un contador o flag independiente, sync.Mutex para una invariante entre campos y channel para transferir trabajo. No copies un mutex después del primer uso ni mantengas un lock durante I/O remoto.
Backpressure es una política de producto y operación. Si la ingesta recibe 50 mil mensajes por segundo y la persistencia completa 20 mil, guardar el resto en memoria solo desplaza el incidente. Decide si bloquear productor, rechazar con retry, pausar consumo para que el broker retenga backlog durable, descartar muestras antiguas, agregar datos o persistir en disco. Define tamaño de cola, métrica de ocupación, timeout y acción de saturación.
Pipeline IoT resistente a reentregas
dispositivo -> MQTT/broker -> ingesta Go -> stream -> procesadores -> almacenamiento
\-> DLQ \-> estado actual
MQTT encaja en la conectividad de dispositivos. Un stream como Kafka encaja en retención durable, replay y particionamiento interno. gRPC es RPC interno tipado; WebSocket actualiza dashboards. Resuelven fronteras distintas.
Varios workers rompen el orden global. Telemetría suele necesitar orden por dispositivo, así que particiona por una clave estable como hash(device_id) % N y procesa cada partición de forma secuencial. Guarda observed_at, ingested_at, sequence, event_id y boot_id cuando exista. El reloj del dispositivo puede desfasarse o reiniciarse.
Diseña la cadena para entrega at least once. Recibe el evento, valida envelope y versión del schema, comprueba la clave de idempotencia, persiste efecto y marcador de deduplicación en la misma transacción cuando sea posible y recién entonces hace ACK. Una transactional outbox cierra la ventana entre confirmar el estado en base y publicar el evento siguiente. El consumer sigue necesitando idempotencia porque las duplicatas pueden ocurrir.
Retry sirve para fallos transitorios. Un payload inválido o una regla de negocio rechazada no mejora con otro intento. Añade límite, budget total y jitter para que las réplicas no repitan juntas.
En el borde usa TLS, identidad por dispositivo, autorización por tópico, límite estricto de payload y validación antes de asignar estructuras grandes. Rotación, revocación, secuencia, nonce y ventana temporal importan cuando el protocolo debe resistir replay.
Kubernetes, observabilidad y rendimiento
En SIGTERM, quita readiness, deja de buscar trabajo, drena el trabajo en vuelo dentro del grace period, confirma solo los mensajes concluidos y cierra productores, conexiones y telemetría al final. Liveness pregunta si el proceso progresa y no debe depender de cada servicio externo. Readiness pregunta si ese pod puede aceptar trabajo ahora.
Para escalar consumers, CPU por sí sola es una señal débil. Observa lag, edad del mensaje más antiguo, tasa de llegada, tiempo de procesamiento y ocupación del pool.
Usa logs estructurados y campos de correlación sin registrar credenciales ni payloads sensibles completos. Mide throughput, errores por clase, p50/p95/p99, lag, edad del evento, retries, DLQ, duplicatas, goroutines, heap y pausas de GC. Usa tracing muestreado para cruzar ingesta, stream y persistencia; trazar cada lectura de alta frecuencia puede costar más de lo que ayuda.
Una data race es acceso concurrente a la misma posición de memoria con al menos una escritura y sin orden de sincronización. Envíos por channel, unlock/lock de mutex y operaciones atómicas establecen relaciones de orden. El detector solo cubre rutas ejecutadas:
go test -race ./...
go test -bench=. -benchmem ./...
go tool pprof cpu.out
go tool trace trace.out
G es goroutine, M es hilo del sistema y P es recurso lógico de ejecución. GOMAXPROCS limita cuántos Ps ejecutan código Go en paralelo, no cuántas goroutines pueden existir. Las goroutines son ligeras, no gratuitas. Mide antes de usar pools u optimizar microdetalles. Preasigna capacidad conocida para slices, evita conversiones repetidas entre string y []byte en hot paths y trata sync.Pool como cache oportunista de temporales.
Respuestas para entrevista
¿Una goroutine es un hilo? No. Es una unidad ligera gestionada por el runtime y multiplexada sobre hilos del sistema operativo.
¿Channel o mutex? Channel para transferir trabajo u ownership; mutex para proteger estado compartido e invariantes.
¿Quién cierra un channel? El productor que sabe que no habrá más envíos.
¿Cómo preservar orden con workers? Evita orden global si no es requisito. Particiona por una clave como device_id y procesa cada partición secuencialmente.
¿Cómo manejas duplicatas? Con una clave de idempotencia estable, deduplicación transaccional cuando sea posible y operaciones idempotentes, como upsert con versión o secuencia.
¿Cómo investigas latencia? Separa tiempo de fila, procesamiento y dependencias. Compara p95/p99, lag y saturación; después prueba una hipótesis con tracing, pprof o go tool trace.
Checklist de producción
- ¿Cada goroutine tiene owner y condición de término?
- ¿Dónde se propagan cancelación y deadline del contexto?
- ¿Cuál es el límite de concurrencia y qué pasa en saturación?
- ¿El orden necesario es por clave o global?
- ¿Cuándo ocurre ACK y cómo la mutación resiste reentrega?
- ¿Qué fallos hacen retry, van a DLQ o a cuarentena?
- ¿Cómo se diferencian tiempo del dispositivo y tiempo de ingesta?
- ¿Qué métricas exponen lag, p99, heap, GC y contención?