Challenge: Build a multi-tenant event ingestion platform that handles high write volume (target: 50k events/min) with strict tenant isolation and sub-100ms query latency.
Non-Functional Requirements:
- Throughput: 1000 events/sec sustained, 2000 events/sec burst
- Latency: P99 < 100ms for ingestion, P99 < 50ms for analytics queries
- Availability: 99.9% uptime (43 min downtime/month)
- Isolation: Zero cross-tenant data leakage
- Idempotency: Duplicate events rejected (client retries safe)
Constraints:
- Small team (1-2 engineers) → operational simplicity matters
- Budget-conscious → avoid over-engineering
- Rapid iteration → Docker Compose before Kubernetes
graph TB
Client[Client Applications]
LB[Load Balancer]
subgraph "Spring Boot Instances"
API1[API Instance 1]
API2[API Instance N]
end
subgraph "Data Layer"
PG[(PostgreSQL<br/>Events + Aggregations)]
Redis[(Redis<br/>Cache + Idempotency)]
RMQ[RabbitMQ<br/>Event Queue]
end
subgraph "Worker Pool"
W1[Worker 1]
W2[Worker N]
end
subgraph "Observability"
Prom[Prometheus]
Graf[Grafana]
end
Client -->|JWT/API Key| LB
LB --> API1
LB --> API2
API1 -->|Publish| RMQ
API2 -->|Publish| RMQ
API1 -->|Cache/Rate Limit| Redis
API2 -->|Cache/Rate Limit| Redis
API1 -->|Query| PG
API2 -->|Query| PG
RMQ -->|Consume| W1
RMQ -->|Consume| W2
W1 -->|Persist| PG
W2 -->|Persist| PG
W1 -->|Dedup Check| Redis
W2 -->|Dedup Check| Redis
API1 -->|Metrics| Prom
API2 -->|Metrics| Prom
Prom --> Graf
Decision: RabbitMQ for async event processing
Rationale:
- Simpler operations: No ZooKeeper, easier local dev with Docker Compose
- Sufficient throughput: Handles 10k msg/sec (our target: 2k/sec)
- Manual acknowledgment: Explicit control over message processing (no data loss)
- Mature Spring integration: Spring AMQP provides robust abstractions
Trade-off: Lower max throughput than Kafka (~10k vs 100k msg/sec), but operational simplicity wins at current scale.
When to revisit: If sustained load exceeds 5k events/sec or need log compaction.
Decision: Redis for deduplication checks before PostgreSQL
Rationale:
- Speed: Sub-millisecond lookups vs 50ms DB query (100x faster)
- TTL support: Auto-expire keys after 1 hour (no cleanup job needed)
- Atomic operations:
SET NX EXprevents race conditions - Defense in depth: DB unique constraint catches duplicates if Redis fails
Trade-off: Redis failure → fall through to DB (slower but functional). Acceptable because correctness guaranteed by DB constraint.
When to revisit: If Redis becomes single point of failure (solution: Redis Sentinel).
Decision: Hourly rollup table instead of real-time aggregation
Problem: Full table scan on 10M events = 5s query time (unacceptable)
Solution:
- Scheduled job aggregates events into
event_aggregationstable - Query scans 720 rows/month instead of millions
- Result: 5s → 50ms (100x improvement)
Trade-off:
- Storage cost: 2x data (raw + aggregated)
- Granularity: Hourly buckets (not minute-level)
- Freshness: Up to 1 hour delay for latest data
When to revisit: If real-time analytics required (solution: Kafka + ksqlDB streaming aggregations).
Decision: Return 202 Accepted immediately, process via queue
Rationale:
- Decouples write path: API doesn't wait for DB commit
- Handles traffic spikes: Queue absorbs bursts (up to 10k messages)
- Graceful degradation: If DB slow, API stays responsive
Trade-off: Eventual consistency (1-2 second delay from ingestion to persistence).
When to revisit: If clients require synchronous confirmation (rare for analytics use case).
sequenceDiagram
participant C as Client
participant A as API
participant R as Redis
participant Q as RabbitMQ
participant W as Worker
participant D as PostgreSQL
C->>A: POST /v1/ingest + API Key
A->>A: Validate API Key (cached)
A->>A: Extract workspace_id
A->>Q: Publish event message
A->>C: 202 Accepted
Q->>W: Consume message
W->>R: Check idempotency key
alt Key exists
W->>Q: ACK (skip duplicate)
else Key missing
W->>R: Set idempotency key (TTL)
W->>D: INSERT event
W->>Q: ACK
end
sequenceDiagram
participant C as Client
participant A as API
participant R as Redis
participant D as PostgreSQL
C->>A: GET /analytics + JWT
A->>A: Validate JWT
A->>A: Extract workspace_id
A->>R: Check cache (query hash)
alt Cache hit
R->>A: Return cached result
A->>C: 200 OK (cached)
else Cache miss
A->>D: Query aggregations (filtered by workspace_id)
D->>A: Result set
A->>R: Cache result (TTL: 5min)
A->>C: 200 OK
end
graph LR
subgraph "Workspace A"
UA[User A]
PA[Project A1]
EA[Events A]
end
subgraph "Workspace B"
UB[User B]
PB[Project B1]
EB[Events B]
end
UA -->|workspace_id=1| PA
PA -->|workspace_id=1| EA
UB -->|workspace_id=2| PB
PB -->|workspace_id=2| EB
style EA fill:#e1f5e1
style EB fill:#e1e5f5
Enforcement:
- All queries filtered by
workspace_idat repository layer - JWT/API Key contains workspace context
- Database indexes include
workspace_idas first column
erDiagram
WORKSPACE ||--o{ PROJECT : contains
WORKSPACE ||--o{ MEMBERSHIP : has
WORKSPACE ||--o{ API_KEY : owns
USER ||--o{ MEMBERSHIP : belongs_to
PROJECT ||--o{ EVENT : receives
EVENT ||--o{ AGGREGATION : rolls_up_to
WORKSPACE {
bigint id PK
string name
timestamp created_at
}
USER {
bigint id PK
string email UK
string password_hash
timestamp created_at
}
MEMBERSHIP {
bigint id PK
bigint workspace_id FK
bigint user_id FK
string role
}
PROJECT {
bigint id PK
bigint workspace_id FK
string name
timestamp created_at
}
API_KEY {
bigint id PK
bigint workspace_id FK
string key_hash UK
timestamp created_at
}
EVENT {
bigint id PK
bigint workspace_id FK
bigint project_id FK
string idempotency_key UK
jsonb payload
timestamp event_time
timestamp ingested_at
}
AGGREGATION {
bigint id PK
bigint workspace_id FK
bigint project_id FK
timestamp hour_bucket
string event_type
bigint count
}
Answer: PostgreSQL writes at ~2000 events/sec
Evidence:
- Single-threaded writes (even with connection pool)
- Disk I/O becomes bottleneck
- HikariCP max pool size (10) limits concurrent writes
Solutions (in order of complexity):
- Batch inserts (20 events/txn) → 5x throughput improvement
- Read replicas for analytics queries (offload SELECT traffic)
- Partition by workspace_id (horizontal sharding)
Current implementation: Batch inserts enabled (hibernate.jdbc.batch_size: 20)
RabbitMQ down:
- API returns 503 Service Unavailable
- Clients retry with exponential backoff
- Health check fails → load balancer stops routing traffic
Redis down:
- Idempotency checks fall through to DB (slower, but functional)
- Cache misses → all queries hit PostgreSQL (degraded performance)
- Rate limiting disabled (fail open to preserve availability)
PostgreSQL down:
- Messages queue in RabbitMQ (up to 10k buffered)
- Workers retry with exponential backoff (max 3 attempts)
- Failed messages route to Dead Letter Queue
- API returns 503 after queue full
Worker crash:
- RabbitMQ redelivers unacknowledged messages
- Other workers continue processing
- No data loss (manual ACK only after DB commit)
| Decision | Benefit | Cost | When to Revisit |
|---|---|---|---|
| Async ingestion | Handles traffic spikes, decoupled architecture | Eventual consistency (1-2s delay) | If real-time confirmation required |
| Pre-aggregations | Fast queries (50ms vs 5s) | 2x storage, hourly granularity | If minute-level precision needed |
| Manual ACK | No message loss, explicit error handling | Complex retry logic | Never (correctness > simplicity) |
| Single PostgreSQL | Simple operations, ACID guarantees | Write bottleneck at 2k/s | At 5k events/sec sustained |
| Redis caching | 100x faster lookups (2ms vs 200ms) | Cache invalidation complexity | If consistency more critical than speed |
| Docker Compose | Fast local dev, simple deployment | Not production-grade | When traffic exceeds single-host capacity |
| RabbitMQ over Kafka | Simpler ops, sufficient throughput | Lower max throughput (10k vs 100k) | At 5k events/sec or need log compaction |
Local Development:
docker-compose up -d # PostgreSQL + Redis + RabbitMQ
./mvnw spring-boot:run
curl http://localhost:8080/actuator/healthWhy Docker Compose?
- Sufficient for 1k req/s (current load)
- Entire stack runs on single machine
- Fast iteration (no K8s complexity)
- Production-like environment locally
Limitations:
- Single point of failure (no HA)
- Vertical scaling only
- Manual deployment process
Production Architecture:
graph TB
subgraph "Production Environment"
subgraph "Compute"
K8S[Kubernetes Cluster]
POD1[API Pod 1]
POD2[API Pod N]
WPOD1[Worker Pod 1]
WPOD2[Worker Pod N]
end
subgraph "Managed Services"
RDS[(RDS PostgreSQL<br/>Multi-AZ)]
ELASTICACHE[(ElastiCache Redis<br/>Cluster Mode)]
MQ[Amazon MQ<br/>RabbitMQ]
end
subgraph "Observability"
CW[CloudWatch Logs]
PROM[Prometheus]
GRAF[Grafana]
end
K8S --> POD1
K8S --> POD2
K8S --> WPOD1
K8S --> WPOD2
POD1 --> RDS
POD2 --> RDS
POD1 --> ELASTICACHE
POD2 --> ELASTICACHE
POD1 --> MQ
POD2 --> MQ
WPOD1 --> RDS
WPOD2 --> RDS
WPOD1 --> ELASTICACHE
WPOD2 --> ELASTICACHE
WPOD1 --> MQ
WPOD2 --> MQ
POD1 --> CW
POD2 --> CW
POD1 --> PROM
POD2 --> PROM
PROM --> GRAF
end
Why Not K8s Yet?
- Docker Compose handles current load (1k req/s)
- Premature optimization adds operational burden
- Small team (1-2 engineers) → simplicity matters
- K8s justified when horizontal scaling required
Migration Path:
- Containerize app (Dockerfile with multi-stage build)
- Deploy to single EC2 instance (Docker Compose)
- Add load balancer when traffic grows
- Migrate to ECS/EKS when multi-host needed
| Component | Scaling Method | Trigger | Max Capacity |
|---|---|---|---|
| API Pods | Horizontal (HPA) | CPU > 70% or RPS > 1000 | 10 pods |
| Worker Pods | Horizontal (HPA) | Queue depth > 1000 | 20 pods |
| PostgreSQL | Vertical + Read Replicas | CPU > 80% | 3 replicas |
| Redis | Cluster Mode | Memory > 80% | 6 shards |
| RabbitMQ | Cluster | Queue depth > 10000 | 3 nodes |
Bottleneck: PostgreSQL writes (current max: 2k/sec with batching)
Solutions:
-
Partition RabbitMQ queues by workspace_id
- Parallel processing across workers
- Reduces contention on single queue
- Implementation: Consistent hashing on workspace_id
-
PostgreSQL sharding
- Partition
eventstable by workspace_id - Each shard handles subset of tenants
- Requires application-level routing
- Partition
-
Redis cluster mode
- Distribute cache across 6 shards
- Handles 100k ops/sec (current: 10k)
- Automatic failover with Sentinel
-
Read replicas for analytics
- Offload SELECT queries to replicas
- Primary handles writes only
- Replication lag: 1-2 seconds acceptable
Current limitation: Hourly aggregations (up to 1 hour delay)
Solution:
- Kafka + ksqlDB for streaming aggregations
- Materialized views in PostgreSQL (incremental refresh)
- WebSocket API to push updates to clients
- Time-series DB (TimescaleDB) for high-cardinality metrics
Trade-off: 10x operational complexity for real-time capability
Current limitation: Single-region deployment (high latency for distant users)
Solution:
- PostgreSQL cross-region replication (async, read replicas per region)
- RabbitMQ federation (queue mirroring across regions)
- Redis geo-replication (active-active with conflict resolution)
- Global load balancer (route to nearest region)
Trade-off: Eventual consistency across regions, conflict resolution complexity
Current gap: No audit logging, data retention policies, or encryption at rest
Solution:
- Audit log table (who accessed what, when)
- Data retention policies (auto-delete events after 90 days)
- Encryption at rest (PostgreSQL TDE, Redis encryption)
- Field-level encryption for sensitive payload data
- Right to deletion (cascade delete by workspace_id)
Trade-off: Performance overhead (encryption), storage cost (audit logs)