Dealing With Contention¶
The Problem¶
Contention occurs when multiple processes/threads/users try to access or modify the same resource simultaneously, leading to:
- Race conditions
- Data corruption
- Lost updates
- Deadlocks
- Performance degradation
Common Scenarios: - Two users editing the same document - Multiple requests updating inventory count - Concurrent bank transfers from same account - High-traffic ticket booking (concerts, flights) - Flash sales with limited inventory - Counter increments (likes, views)
Options & Trade-offs¶
1. Pessimistic Locking¶
Philosophy: "Assume conflicts will happen, prevent them upfront"
Implementation Types:
Database Row-Level Locks¶
-- Exclusive lock (write)
SELECT * FROM inventory
WHERE product_id = 123
FOR UPDATE;
-- Shared lock (read)
SELECT * FROM inventory
WHERE product_id = 123
FOR SHARE;
-- With timeout (PostgreSQL)
SET lock_timeout = '5s';
SELECT * FROM inventory WHERE id = 123 FOR UPDATE;
Distributed Locks (Redis)¶
// Using Redisson
RLock lock = redisson.getLock("inventory:123");
try {
// Wait up to 10 seconds, lock expires after 30 seconds
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
try {
// Critical section
updateInventory();
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Redlock Algorithm (Multiple Redis Nodes)¶
1. Get current time
2. Try to acquire lock on N/2+1 nodes
3. If successful on majority within timeout → lock acquired
4. If failed → release all locks, retry
| Pros | Cons |
|---|---|
| Guarantees consistency | Reduces concurrency/throughput |
| Simple mental model | Risk of deadlocks |
| Prevents conflicts entirely | Lock contention under high load |
| Good for high-conflict scenarios | Increased latency |
| Single point of failure (lock server) |
When to use: - Financial transactions - Inventory with high contention - Any operation that MUST NOT have conflicts - Short-duration operations
2. Optimistic Locking¶
Philosophy: "Assume conflicts are rare, detect them when they happen"
Implementation Types:
Version Numbers¶
-- Read with version
SELECT id, quantity, version FROM inventory WHERE id = 123;
-- Returns: id=123, quantity=10, version=5
-- Update with version check
UPDATE inventory
SET quantity = 9, version = version + 1
WHERE id = 123 AND version = 5;
-- If affected_rows = 0 → conflict, retry
Timestamps¶
UPDATE inventory
SET quantity = 9, updated_at = NOW()
WHERE id = 123 AND updated_at = '2024-01-15 10:30:00';
ETags (HTTP)¶
GET /api/document/123
Response:
ETag: "abc123"
PUT /api/document/123
If-Match: "abc123"
Content-Type: application/json
{"content": "updated"}
Response (if changed): 412 Precondition Failed
Response (if success): 200 OK, ETag: "def456"
JPA/Hibernate¶
@Entity
public class Inventory {
@Id
private Long id;
@Version
private Long version;
private Integer quantity;
}
// Throws OptimisticLockException on conflict
| Pros | Cons |
|---|---|
| High concurrency | Retry overhead on conflicts |
| No lock contention | Complex retry logic |
| No deadlocks | May starve under high contention |
| Good for read-heavy workloads | Not suitable for long transactions |
| Works well in distributed systems |
When to use: - Read-heavy workloads - Low conflict probability - User-facing edits (documents, profiles) - APIs with ETag support
3. Atomic Operations¶
Philosophy: "Make the operation indivisible at the lowest level"
Database Atomic Updates:
-- Instead of: SELECT then UPDATE
-- Do: Single atomic UPDATE
UPDATE inventory
SET quantity = quantity - 1
WHERE id = 123 AND quantity > 0;
-- Check affected_rows to know if successful
Redis Atomic Operations:
# Atomic increment
INCR page_views:article:123
# Atomic decrement with check
EVAL "
local current = redis.call('GET', KEYS[1])
if tonumber(current) > 0 then
return redis.call('DECR', KEYS[1])
else
return -1
end
" 1 inventory:product:123
Compare-And-Swap (CAS):
AtomicInteger counter = new AtomicInteger(0);
// Atomic increment
counter.incrementAndGet();
// Compare and swap
int expected = 5;
int newValue = 6;
boolean success = counter.compareAndSet(expected, newValue);
| Pros | Cons |
|---|---|
| Highest performance | Limited to simple operations |
| No lock overhead | Can't span multiple resources |
| Database handles concurrency | Business logic must fit pattern |
| Simple implementation |
When to use: - Counters (views, likes) - Simple inventory decrements - Rate limiting - Any single-field update
4. Queue-Based Serialization¶
Philosophy: "Serialize all writes through a single queue"
Implementation:
// Partition by resource ID for ordering
@KafkaListener(topics = "inventory-updates")
public void processUpdate(InventoryUpdate update) {
// All updates for same product processed serially
inventoryService.update(update);
}
// Producer partitions by product ID
kafkaTemplate.send("inventory-updates",
productId.toString(), // partition key
update);
| Pros | Cons |
|---|---|
| Guaranteed ordering | Added latency (async) |
| No conflicts possible | Queue becomes bottleneck |
| Can batch operations | Complexity of queue infrastructure |
| Natural audit log | Error handling complexity |
| Not suitable for sync operations |
When to use: - Order processing - Event sourcing - When eventual consistency is OK - High-write scenarios that can be async
5. Sharding / Partitioning¶
Philosophy: "Divide resources so they don't contend"
Strategies:
- Hash-based: shard = hash(id) % num_shards
- Range-based: A-H → Shard1, I-P → Shard2
- Geo-based: US → Shard1, EU → Shard2
- Tenant-based: Customer1 → Shard1
| Pros | Cons |
|---|---|
| Linear scalability | Cross-shard operations complex |
| Reduced contention per shard | Rebalancing challenges |
| Data locality | Hotspots possible |
| Increased operational complexity |
When to use: - Large-scale systems - Multi-tenant applications - When data naturally partitions (users, regions)
6. CRDTs (Conflict-free Replicated Data Types)¶
Philosophy: "Design data structures that automatically merge without conflicts"
Types: - G-Counter: Grow-only counter - PN-Counter: Positive-Negative counter - G-Set: Grow-only set - LWW-Register: Last-Writer-Wins register - OR-Set: Observed-Remove set
G-Counter Example:
Node A: {A: 3, B: 0, C: 0} → total = 3
Node B: {A: 0, B: 5, C: 0} → total = 5
Node C: {A: 0, B: 0, C: 2} → total = 2
Merged: {A: 3, B: 5, C: 2} → total = 10
Implementation:
public class GCounter {
private Map<String, Long> counts = new HashMap<>();
private String nodeId;
public void increment() {
counts.merge(nodeId, 1L, Long::sum);
}
public long value() {
return counts.values().stream().mapToLong(Long::longValue).sum();
}
public void merge(GCounter other) {
other.counts.forEach((key, value) ->
counts.merge(key, value, Math::max));
}
}
| Pros | Cons |
|---|---|
| No conflicts by design | Limited operation types |
| Works offline | Can grow unbounded (tombstones) |
| Eventually consistent | Complex to implement correctly |
| Great for distributed systems | May not match business requirements |
When to use: - Collaborative editing - Distributed counters - Shopping carts - Offline-first applications
7. Application-Level Techniques¶
Idempotent Operations¶
// Instead of: inventory -= 1
// Use idempotent: set inventory to specific value
@Transactional
public void processOrder(String orderId, int productId) {
if (orderAlreadyProcessed(orderId)) {
return; // Idempotent
}
// Process order
markOrderProcessed(orderId);
}
Read-Your-Writes¶
// After write, read from primary
// Avoids reading stale data from replica
public Order createOrder(OrderRequest request) {
Order order = orderRepository.save(request);
// Force read from primary
return orderRepository.findByIdFromPrimary(order.getId());
}
Lease-Based Locks¶
// Lock with automatic expiration
String lockValue = UUID.randomUUID().toString();
boolean acquired = redis.set("lock:resource", lockValue, "NX", "EX", 30);
if (acquired) {
try {
// Do work, extend lease if needed
redis.expire("lock:resource", 30);
} finally {
// Only release if we still own it
String script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
redis.eval(script, "lock:resource", lockValue);
}
}
Comparison Matrix¶
| Approach | Consistency | Throughput | Complexity | Best For |
|---|---|---|---|---|
| Pessimistic Lock | Strong | Low | Medium | Financial, inventory |
| Optimistic Lock | Strong | High | Medium | Low-conflict updates |
| Atomic Operations | Strong | Very High | Low | Counters, simple updates |
| Queue Serialization | Eventual | High | High | Event processing |
| Sharding | Strong (per shard) | Very High | High | Scale-out |
| CRDTs | Eventual | Very High | Very High | Distributed, offline |
Decision Tree¶
Real-World Examples¶
Flash Sale (High Contention)¶
Strategy: Queue + Pessimistic Lock + Sharding
1. Requests hit rate limiter
2. Valid requests queued by product ID
3. Workers process with DB locks
4. Inventory sharded by product category
Collaborative Document Editing¶
Strategy: CRDTs + Operational Transform
1. Each keystroke = operation
2. Operations merged using OT/CRDT
3. Conflicts auto-resolved
4. Periodic snapshots for efficiency
Bank Account Transfer¶
Strategy: Pessimistic Lock + Two-Phase Commit
1. Lock source account
2. Lock destination account (ordered by ID to prevent deadlock)
3. Verify sufficient balance
4. Debit source, credit destination
5. Release locks
Social Media Likes Counter¶
Strategy: Atomic Operations + Eventual Consistency
1. Increment in Redis (atomic)
2. Async sync to database
3. Display approximate count
4. Exact count calculated periodically
Anti-Patterns to Avoid¶
- Lock and forget - Always have timeouts
- Nested locks - Deadlock risk
- Long-held locks - Blocks others
- Ignoring failures - Silent corruption
- Over-locking - Performance killer
- SELECT then UPDATE - Race condition
Key Takeaways¶
- Understand your conflict rate - Optimistic for low, pessimistic for high
- Atomic operations first - Simplest solution if possible
- Timeouts always - Prevent indefinite waits
- Order lock acquisition - Prevent deadlocks
- Consider eventual consistency - Often acceptable, much simpler
- Shard when possible - Best way to eliminate contention