ElastiCache (Redis/Memcached), chiến lược cache, session store trên AWS
Tiếp đi: ElastiCache. Trang chi tiết sản phẩm của shop-app gọi RDS mỗi request: lấy sản phẩm, lấy giá khuyến mãi, lấy số đánh giá. Ba query cho một trang mà sản phẩm thì đổi vài lần một ngày. Ở chương Redis mình đã biết cache-aside rồi, giờ trên AWS mình nên tự cài Redis trên EC2 hay dùng ElastiCache? Và session của Spring Security để đâu khi app chạy 2–4 instance sau ALB?
Hai câu hỏi của bạn thực ra là một: cần một Redis dùng chung cho nhiều instance app, vừa cache dữ liệu đọc nhiều, vừa giữ session. Trên AWS câu trả lời gần như luôn là ElastiCache, không tự cài. Phần "Redis bên trong hoạt động thế nào" (data type, persistence, replication, cluster, lock) đã ở chương Redis (7); bài này chỉ nói phần AWS: dịch vụ managed, mạng, chi phí, và cách Spring Boot trên EC2 nối vào.
1. Bài toán: trang sản phẩm đọc 3 query, 4 instance app, session rơi
GET /products/42
├─ SELECT * FROM product WHERE id = 42 ~3 ms
├─ SELECT * FROM promotion WHERE product_id = 42 ... ~5 ms
└─ SELECT count(*), avg(star) FROM review WHERE ... ~40 ms (bảng lớn)
Đợt sale: 2.000 req/s trang sản phẩm → 6.000 query/s vào shop-db db.t3.micro
→ CPU RDS 95%, connection pool đầy, checkout (quan trọng hơn) cũng chậm theo
Và từ khi ASG shop-asg chạy 2–4 instance (bài 22.5), người dùng đăng nhập xong, request sau rơi vào instance khác, mất session, bị văng ra login.
2. Vấn đề
- Đọc lặp lại cùng dữ liệu: sản phẩm 42 được đọc 2.000 lần/s nhưng chỉ đổi 3 lần/ngày. RDS trả giá cho 2.000 lần tính lại.
- RDS là tài nguyên đắt và khó scale nhất trong stack; cache rẻ hơn nhiều lần mỗi GB và mỗi request.
- Session trong JVM (
HttpSessionmặc định) chỉ sống trong một instance. ALB có sticky session (cookieAWSALB), nhưng instance scale-in hoặc chết là mất session, và phân tải lệch. - Tự cài Redis trên EC2: bạn phải lo patch, failover, backup, monitoring, Multi-AZ. Ở Redis 7.4 đã thấy Sentinel/Cluster không đơn giản.
- Dùng Caffeine in-process (một cache mỗi instance): 4 instance = 4 bản cache lệch nhau, invalidation phải broadcast.
3. Giải pháp: ElastiCache
Amazon ElastiCache là dịch vụ cache in-memory managed, chạy trong VPC của bạn, hỗ trợ ba engine. Thuật ngữ đề thi:
| Thuật ngữ | Nghĩa | Trên shop-app |
|---|---|---|
| Engine | Redis OSS, Valkey (fork mã mở của Redis, AWS khuyến khích, rẻ hơn khoảng 20%), Memcached | Valkey/Redis |
| Node | Một instance cache, ví dụ cache.t3.micro (~0.5 GB), cache.r7g.large | Lab: cache.t3.micro |
| Replication group (cluster, Redis) | 1 primary + 0–5 replica, Multi-AZ, auto failover | shop-cache |
| Cluster mode disabled | Một shard, toàn bộ data trong một primary; scale đọc bằng replica, scale ghi bằng node to hơn (dọc) | Đủ cho shop-app giai đoạn này |
| Cluster mode enabled | Nhiều shard (tối đa 500), data chia theo hash slot, scale ghi ngang; tối đa ~hàng trăm TB | Khi > vài chục GB hoặc write nặng |
| Multi-AZ với auto failover | Replica ở AZ khác; primary chết → replica lên thay trong khoảng 1 phút (đổi DNS endpoint) | Bật ở prod, tắt ở lab |
| Serverless | Không chọn node, trả theo GB-giờ data và ECPU; scale tự động | Workload lạ, không muốn đo |
| Subnet group | Danh sách private subnet cache được đặt vào | shop-cache-subnets = shop-private-a/b |
| Parameter group | maxmemory-policy, notify-keyspace-events… | allkeys-lru cho cache; volatile-lru nếu trộn session |
| Global Datastore | Replicate Redis cross-region (chỉ đọc ở region phụ) | Khi mở shop ở Nhật |
| Encryption | At-rest (KMS), in-transit (TLS), AUTH token / RBAC (user & ACL) | Bật cả ba ở prod |
Ba chiến lược cache đề thi và phỏng vấn đều hỏi (chi tiết, ưu nhược, stampede đã ở Redis 7.2; đây là tên gọi AWS dùng):
Lazy loading (cache-aside) Write-through Thêm TTL
───────────────────────── ───────────────────── ──────────────────────
app → cache? miss app → ghi DB cả hai + mỗi key có TTL
→ đọc DB → ghi cache ngay → data cũ tự hết
→ ghi cache đọc luôn hit, data mới → bớt stale, bớt rác
+ chỉ cache thứ được đọc + không stale Đề: "stale data acceptable
- miss đầu chậm (3 bước) - mỗi write tốn thêm for N minutes" → TTL = N
- data stale tới khi hết TTL - cache đầy thứ không ai đọc
Thực tế shop-app dùng cache-aside + TTL cho sản phẩm (đọc nhiều, chịu stale 5 phút) và write-through cho giỏ hàng nếu để trong Redis (phải đúng ngay).
4. Code / cấu hình từng bước
Bước 1: Mạng. ElastiCache luôn nằm trong VPC, không có public endpoint. Nó cần subnet group và security group:
export AWS_REGION=ap-southeast-1
VPC_ID=$(aws ec2 describe-vpcs --profile shop-lab --filters Name=tag:Name,Values=shop-vpc --query 'Vpcs[0].VpcId' --output text)
PRIV_A=$(aws ec2 describe-subnets --profile shop-lab --filters Name=tag:Name,Values=shop-private-a --query 'Subnets[0].SubnetId' --output text)
PRIV_B=$(aws ec2 describe-subnets --profile shop-lab --filters Name=tag:Name,Values=shop-private-b --query 'Subnets[0].SubnetId' --output text)
APP_SG=$(aws ec2 describe-security-groups --profile shop-lab --filters Name=group-name,Values=shop-app-sg --query 'SecurityGroups[0].GroupId' --output text)
aws elasticache create-cache-subnet-group --profile shop-lab \
--cache-subnet-group-name shop-cache-subnets \
--cache-subnet-group-description "shop-app private subnets" \
--subnet-ids $PRIV_A $PRIV_B \
--tags Key=Project,Value=shop-app Key=Env,Value=lab
CACHE_SG=$(aws ec2 create-security-group --profile shop-lab --group-name shop-cache-sg \
--description "Redis 6379 from shop-app-sg" --vpc-id $VPC_ID \
--tag-specifications 'ResourceType=security-group,Tags=[{Key=Name,Value=shop-cache-sg},{Key=Project,Value=shop-app},{Key=Env,Value=lab}]' \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress --profile shop-lab --group-id $CACHE_SG \
--protocol tcp --port 6379 --source-group $APP_SG
Rule "6379 từ shop-app-sg" (không phải từ CIDR) là cách SG tham chiếu SG đã học ở bài 22.3: instance nào mang shop-app-sg thì vào được, bất kể IP.
Bước 2: Tạo replication group (Redis, cluster mode disabled, 1 node cho lab).
aws elasticache create-replication-group --profile shop-lab \
--replication-group-id shop-cache \
--replication-group-description "shop-app cache + session" \
--engine redis --engine-version 7.1 \
--cache-node-type cache.t3.micro \
--num-cache-clusters 1 \
--cache-subnet-group-name shop-cache-subnets \
--security-group-ids $CACHE_SG \
--transit-encryption-enabled --at-rest-encryption-enabled \
--tags Key=Project,Value=shop-app Key=Env,Value=lab
# Prod: --num-cache-clusters 2 --multi-az-enabled --automatic-failover-enabled
# --auth-token "$(aws secretsmanager get-secret-value ...)" hoặc --user-group-ids (RBAC)
aws elasticache wait replication-group-available --profile shop-lab --replication-group-id shop-cache
CACHE_HOST=$(aws elasticache describe-replication-groups --profile shop-lab --replication-group-id shop-cache \
--query 'ReplicationGroups[0].NodeGroups[0].PrimaryEndpoint.Address' --output text)
echo $CACHE_HOST # shop-cache.xxxxxx.ng.0001.apse1.cache.amazonaws.com
Dùng Primary endpoint cho ghi/đọc; Reader endpoint phân tải đọc lên replica. Khi failover, AWS trỏ lại DNS của primary endpoint; client chỉ cần resolve lại (Lettuce làm sẵn).
Bước 3: Dependency Spring Boot.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId> <!-- kèm Lettuce -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
Bước 4: application.yml. Host từ biến môi trường (user data EC2 hoặc SSM Parameter /shop/app/cache-host), không hardcode; TLS bật vì ta tạo cluster với --transit-encryption-enabled.
spring:
data:
redis:
host: ${SHOP_CACHE_HOST}
port: 6379
ssl:
enabled: true
timeout: 500ms
lettuce:
pool:
max-active: 16
shutdown-timeout: 100ms
# password: ${SHOP_CACHE_AUTH} # nếu bật AUTH token (lấy từ Secrets Manager)
cache:
type: redis
redis:
time-to-live: 5m # TTL mặc định cho mọi cache
cache-null-values: false
key-prefix: "shop:"
use-key-prefix: true
session:
store-type: redis # Boot 3.x: tự cấu hình khi có spring-session-data-redis
timeout: 30m
redis:
namespace: "shop:session"
Bước 5: Cache-aside bằng Spring Cache (annotation đã học ở Spring Core 3.8; đây là phần nối vào ElastiCache):
@Service
public class ProductService {
private final ProductRepository repo;
private final ReviewRepository reviews;
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public ProductView get(Long id) { // miss → DB → put, TTL 5m
Product p = repo.findById(id).orElseThrow();
ReviewStats s = reviews.statsFor(id); // query 40 ms chỉ chạy khi miss
return ProductView.of(p, s);
}
@CacheEvict(cacheNames = "product", key = "#p.id") // admin sửa → xóa key, lần đọc sau nạp mới
public Product update(Product p) { return repo.save(p); }
@CachePut(cacheNames = "product", key = "#result.id") // write-through nếu muốn
public ProductView refresh(Long id) { return get(id); }
}
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
RedisCacheManagerBuilderCustomizer cacheTtls() {
return b -> b
.withCacheConfiguration("product",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(5))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())))
.withCacheConfiguration("promotion",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(30)));
}
}
Serializer JSON thay cho JDK serialization mặc định: key đọc được bằng redis-cli, không vỡ khi đổi class, và các service khác (không phải Java) cũng đọc được.
Bước 6: Session store. Với spring-session-data-redis, HttpSession tự lưu vào Redis dưới key shop:session:sessions:<id>; cookie SESSION gửi về trình duyệt. Instance nào cũng đọc được → bỏ sticky session ở ALB:
aws elbv2 modify-target-group-attributes --profile shop-lab \
--target-group-arn $TG_ARN --attributes Key=stickiness.enabled,Value=false
Nếu shop-app dùng JWT stateless (chương Spring Security) thì không cần session store; Redis khi đó giữ refresh token, blacklist và rate limit thay.
Bước 7: Kiểm tra từ EC2 (ElastiCache không truy cập được từ máy bạn; dùng Session Manager vào instance app, bài 22.4):
sudo dnf install -y redis6 # hoặc tải redis-cli
redis-cli -h $SHOP_CACHE_HOST --tls PING # PONG
curl -s localhost:8080/products/42 > /dev/null # miss → DB
redis-cli -h $SHOP_CACHE_HOST --tls KEYS 'shop:product*' # shop:product::42
redis-cli -h $SHOP_CACHE_HOST --tls TTL 'shop:product::42' # ~300
5. Cách hoạt động
Internet
│
shop-alb (public subnet, không sticky)
┌─────┴─────┐
▼ ▼
shop-private-a shop-private-b
┌──────────────────┐ ┌──────────────────┐
│ EC2 shop-app #1 │ │ EC2 shop-app #2 │ (shop-app-sg)
│ Lettuce pool │ │ Lettuce pool │
└───────┬──────────┘ └────────┬─────────┘
│ 6379 TLS (shop-cache-sg: chỉ từ shop-app-sg)
▼ ▼
┌────────────────── shop-cache (replication group) ──────────────────┐
│ Primary (AZ a) ──async──► Replica (AZ b) │
│ primary endpoint (DNS) reader endpoint (DNS, round-robin) │
│ Multi-AZ auto failover: primary chết → promote replica (~1 phút) │
└─────────────────────────────────────────────────────────────────────┘
│ miss
▼
shop-db (RDS, shop-db-sg)
Luồng GET /products/42:
1. @Cacheable → GET shop:product::42
2a. hit → trả về (0.3 ms, không chạm RDS) ~95% request
2b. miss → 3 query RDS (~50 ms) → SET ... EX 300 → trả về ~5% request
3. Admin sửa giá → @CacheEvict → DEL shop:product::42
Khi primary chết: Lettuce nhận lỗi kết nối, resolve lại DNS primary endpoint (TTL DNS ngắn), nối vào replica vừa được promote. Request trong ~30–60 giây đó fail: cache fail thì app vẫn phải chạy bằng cách đọc DB, không được 500. Spring Cache mặc định ném exception khi Redis chết; ghi đè CacheErrorHandler:
@Configuration
public class CacheFailOpen implements CachingConfigurer {
@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() {
@Override public void handleCacheGetError(RuntimeException e, Cache c, Object k) {
log.warn("cache GET failed, fall back to DB: {}", e.getMessage()); // nuốt lỗi → coi như miss
}
@Override public void handleCachePutError(RuntimeException e, Cache c, Object k, Object v) {
log.warn("cache PUT failed: {}", e.getMessage());
}
};
}
}
Đây là "fail-open": cache là tối ưu, không phải nguồn sự thật. Session thì khác: Redis chết là user bị logout, nên prod bắt buộc Multi-AZ.
6. So sánh: những cặp đề thi hay hỏi
| Redis / Valkey | Memcached | |
|---|---|---|
| Data type | String, hash, list, set, sorted set, stream, geo, pub/sub, Lua | Chỉ string/object |
| Replication, Multi-AZ, failover | Có | Không (node chết là mất data node đó) |
| Persistence, backup/restore | Có (RDB snapshot) | Không |
| Scale | Replica (đọc) + cluster mode (shard ghi) | Multi-thread, thêm node (client hash) |
| Encryption, AUTH/RBAC | Có | Có TLS, không RBAC phong phú |
| Đề thi | "leaderboard", "session", "pub/sub", "HA", "persistence", "complex data types" | "simplest caching", "multithreaded", "no persistence needed", "horizontal scale of simple object cache" |
| ElastiCache | DAX (bài 22.8) | CloudFront (bài 22.10) | Caffeine (in-process) | |
|---|---|---|---|---|
| Cache gì | Bất kỳ: object, query result, session, counter | Chỉ DynamoDB item/query | HTTP response ở edge | Object trong JVM |
| Chia sẻ giữa instance | Có | Có | Có (toàn cầu) | Không |
| Độ trễ | < 1 ms trong VPC | micro-giây | vài ms tới user | nano-giây |
| Code | Tự viết hoặc Spring Cache | Đổi client | Không đổi code, chỉ header Cache-Control | Spring Cache |
| Dùng khi | RDS/DB nặng đọc, session, rate limit | DynamoDB đọc nặng | Static, API GET công khai | Config, lookup nhỏ, không đổi |
| Cluster mode disabled | Cluster mode enabled | Serverless | |
|---|---|---|---|
| Dữ liệu | Một shard, ≤ RAM một node (tới ~hàng trăm GB với r7g) | Nhiều shard, chia theo hash slot | Tự scale |
| Scale ghi | Dọc (đổi node type) | Ngang (thêm shard, online resharding) | Tự động |
| Client | Một endpoint | Cluster-aware client (Lettuce hỗ trợ); MULTI/Lua chỉ cùng slot | Một endpoint |
| Đề thi | "< vài chục GB, muốn đơn giản" | "> node lớn nhất", "write throughput", "500 nodes" | "unpredictable", "no capacity planning" |
7. Khi nào dùng / không dùng
- Dùng ElastiCache: dữ liệu đọc nhiều hơn ghi và chấp nhận stale ngắn; session/token store cho nhiều instance; counter, rate limit, leaderboard (sorted set); distributed lock (Redis 7.5); pub/sub nhẹ; giảm tải RDS trước khi nghĩ đến Read Replica (rẻ hơn, nhanh hơn).
- Không dùng: làm database chính (dù Redis có persistence, ElastiCache không hứa durability như RDS/DynamoDB); dữ liệu ghi nhiều đọc ít (cache hit thấp, tốn tiền vô ích); dữ liệu phải đúng tuyệt đối từng ms (số dư ví) trừ khi write-through và chấp nhận phức tạp; cache HTTP cho static (CloudFront rẻ hơn); cache DynamoDB (DAX ít code hơn).
- Tự cài Redis trên EC2 chỉ khi cần module đặc thù (RedisSearch, RedisJSON mà ElastiCache chưa có), hoặc bạn đã có đội vận hành Redis và muốn tối ưu chi phí ở quy mô rất lớn.
Mình thấy ElastiCache không nằm trong Free Tier. Thay vì thêm một dịch vụ nữa, mình nâng RDS từ db.t3.micro lên db.t3.medium cho khỏe có phải đơn giản hơn không? Hai cách này tốn khác nhau thế nào?
Câu hỏi đúng kiểu đề SAA Domain 4 (Cost). Bạn hiểu đúng là ElastiCache không có Free Tier (tất cả node đều tính tiền theo giờ), nhưng kết luận "nâng RDS đơn giản hơn" thường sai về tiền và sai về kỹ thuật. Mình không ghi giá chính xác (đổi theo thời gian, kiểm tra pricing page), chỉ so tỉ lệ:
| Phương án | Chi phí ước lượng (On-Demand, ap-southeast-1) | Hiệu quả với bài toán "đọc lặp 2.000 req/s" |
|---|---|---|
RDS db.t3.micro → db.t3.medium | Khoảng gấp 4 lần micro (~vài chục USD/tháng thêm); nhân đôi nữa nếu Multi-AZ | CPU gấp 2, RAM gấp 4; vẫn chạy 6.000 query/s; vẫn giới hạn connection; đợt sale tiếp theo lại nâng tiếp |
Thêm RDS Read Replica db.t3.micro | ~bằng một instance nữa | Chia tải đọc, nhưng app phải tách datasource đọc/ghi; replica lag; vẫn 6.000 query/s tổng |
ElastiCache cache.t3.micro (1 node) | Khoảng 0.02 USD/giờ, ~15 USD/tháng | Hit 95% → RDS còn ~300 query/s; p99 trang sản phẩm từ 50 ms xuống < 5 ms; đồng thời giải quyết session |
| ElastiCache 2 node Multi-AZ (prod) | ~gấp đôi dòng trên | Như trên + failover ~1 phút |
| ElastiCache Serverless | Tối thiểu ~100 MB data tính tiền → khởi điểm cao hơn t3.micro vài lần, nhưng không phải đoán size | Hợp khi tải thất thường, không hợp lab nhỏ |
Ba lý do cache thắng:
- Cache rẻ hơn mỗi request: Redis phục vụ hơn 100.000 GET/s trên một node nhỏ; PostgreSQL vài nghìn query phức tạp/s trên instance cùng giá.
- Nâng RDS là giải pháp tuyến tính cho bài toán lặp: lần sale sau lại nâng, mỗi lần nâng là downtime vài phút (Multi-AZ thì failover ~1 phút). Cache là giải pháp bậc: hit rate 95% nghĩa là RDS chỉ thấy 5% tải, dù tải tăng 10 lần.
- Session không giải quyết được bằng RDS to hơn; mà bạn cần Redis cho session dù sao.
Khi nào nâng RDS đúng: tải là ghi (checkout, tồn kho) hoặc query đa dạng không lặp (báo cáo). Cache không giúp gì cho write-heavy; lúc đó nâng instance, dùng Aurora, hoặc tách bớt sang DynamoDB như bài 22.8.
Để lab rẻ: tạo cache.t3.micro 1 node, làm xong xóa trong ngày; nếu quên một tháng là ~15 USD. Đặt Budget alert (bài 22.1) 5 USD để biết ngay.
redis-cli -h shop-cache... từ laptop thì timeout. Lỗi ở đâu?Mình đã tạo shop-cache xong, lấy primary endpoint rồi redis-cli -h ... từ laptop thì treo rồi timeout. Security group mình đã mở 6379 từ 0.0.0.0/0 để test luôn mà vẫn không vào được. Vậy làm sao để test, và vì sao mở SG rộng mà vẫn không được?
Chỉnh một điểm quan trọng: ElastiCache không bao giờ có public IP, nên mở SG rộng bao nhiêu cũng vô ích (và nguy hiểm, hãy đóng lại ngay). Endpoint shop-cache.xxxx.cache.amazonaws.com resolve ra IP private trong shop-vpc (10.0.11.x); laptop của bạn không có đường vào mạng đó. Đây là thiết kế cố ý: cache nằm cùng chỗ với app để độ trễ dưới 1 ms và không bao giờ lộ ra Internet.
Ba cách test đúng
Cách 1 (khuyên dùng): Session Manager vào EC2 app
laptop ──(SSM, không cần SSH/port)──► EC2 shop-app (private subnet) ──6379──► shop-cache
aws ssm start-session --profile shop-lab --target $INSTANCE_ID
redis-cli -h $SHOP_CACHE_HOST --tls PING
Cách 2: Port forwarding qua SSM (dùng tool trên laptop, ví dụ RedisInsight)
aws ssm start-session --profile shop-lab --target $INSTANCE_ID \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters "host=$SHOP_CACHE_HOST,portNumber=6379,localPortNumber=6379"
redis-cli -h 127.0.0.1 --tls --sni $SHOP_CACHE_HOST PING
Cách 3: Chạy shop-app test trên EC2/ECS trong VPC, xem log cache hit/miss
Bastion host public + SSH cũng được nhưng là cách cũ: tốn thêm EC2, mở port 22, quản key. SSM Session Manager chỉ cần role EC2 có AmazonSSMManagedInstanceCore (đã có ở shop-app-ec2-role từ bài 22.4), không mở port nào.
Sửa lại security group
aws ec2 revoke-security-group-ingress --profile shop-lab --group-id $CACHE_SG \
--protocol tcp --port 6379 --cidr 0.0.0.0/0
aws ec2 describe-security-groups --profile shop-lab --group-ids $CACHE_SG \
--query 'SecurityGroups[0].IpPermissions' # chỉ còn UserIdGroupPairs = shop-app-sg
Nhân tiện, ba lỗi kết nối hay gặp khác với ElastiCache:
| Triệu chứng | Nguyên nhân | Sửa |
|---|---|---|
PING treo từ EC2 trong VPC | SG cache không nhận từ SG app, hoặc subnet group ở VPC khác | Kiểm tra --source-group, subnet group |
Connection reset ngay lập tức | Cluster bật TLS, client không dùng --tls / ssl.enabled | Bật TLS phía client; SNI khi port-forward |
NOAUTH Authentication required | Bật AUTH token / RBAC mà app thiếu password/username | spring.data.redis.password từ Secrets Manager; RBAC thì thêm username |
MOVED 1234 10.0.12.5:6379 | Cluster mode enabled nhưng client dùng standalone | Lettuce: spring.data.redis.cluster.nodes với configuration endpoint |
Bật cache lên chạy tốt, nhưng hai chuyện: admin đổi giá trong trang quản trị (service khác, không chạy @CacheEvict) mà trang khách vẫn hiện giá cũ tới 5 phút; và lúc 20:00 key sản phẩm sale hết TTL đúng lúc 500 request cùng vào, RDS giật một nhịp. Hai cái này trên AWS có cách nào gọn hơn ngoài việc tự code như chương Redis không?
Bạn gặp đúng hai bài toán cổ điển: invalidation xuyên service và cache stampede (thundering herd). Kỹ thuật gốc (lock khi miss, TTL ngẫu nhiên, refresh-ahead) đã có ở Redis 7.2; phần dưới là cách ghép với dịch vụ AWS sẵn có để ít code nhất.
1. Invalidation xuyên service: dùng event thay vì gọi nhau
Admin service ──ghi giá──► shop-db
│
└─ publish ProductChanged{id=42} ──► SNS shop-order-topic (hoặc EventBridge shop-bus, bài 22.11)
│
┌────────────┴────────────┐
▼ ▼
SQS → shop-app #1 SQS → shop-app #2 ... (hoặc 1 queue, consumer DEL key)
DEL shop:product::42 (Redis dùng chung → 1 lần DEL là đủ)
Vì cache là Redis dùng chung, chỉ cần một consumer xóa key; không cần broadcast tới từng instance như Caffeine. Đơn giản hơn nữa: admin service cũng kết nối cùng shop-cache và DEL thẳng, nhưng vậy là coupling hai service vào cùng key format; event sạch hơn và tiện mở rộng (invalidate CloudFront, cập nhật search index). Chi tiết SNS/SQS ở bài sau.
Cách "AWS-native" khác cho dữ liệu đổi từ DB: bật Postgres logical replication → DMS → Kinesis hoặc Debezium (CDC, distributed 14.7), consumer xóa key. Nặng hơn, chỉ đáng khi nhiều nguồn ghi.
2. Stampede: ba lớp phòng
Lớp 1 – Spring Cache sync=true (lock trong 1 JVM)
@Cacheable(cacheNames="product", key="#id", sync = true)
→ trong một instance chỉ 1 thread đi DB, 499 thread chờ. Nhưng 4 instance = 4 lần DB (chấp nhận được).
Lớp 2 – TTL lệch ngẫu nhiên (jitter) để key không hết cùng lúc
entryTtl(Duration.ofSeconds(300 + ThreadLocalRandom.current().nextInt(60))) // qua custom RedisCacheWriter hoặc TTL theo key
Lớp 3 – Refresh-ahead cho vài key "hot" biết trước (sản phẩm flash sale)
EventBridge Scheduler (rate 4 phút) ──► Lambda / endpoint nội bộ POST /internal/cache/warm?ids=42,43
→ key luôn tươi, không bao giờ miss trong giờ sale
Với lock xuyên instance (chỉ 1 trong 4 instance được đi DB) dùng SET lock:product:42 NX PX 2000 trên chính Redis (Redis 7.5); thường không cần, 4 query thay 500 đã đủ tốt.
3. Đo để biết cache có đang giúp không
# CloudWatch ElastiCache (namespace AWS/ElastiCache, dimension CacheClusterId=shop-cache-001)
CacheHits / (CacheHits + CacheMisses) → hit rate; dưới 80% thì TTL quá ngắn hoặc key quá đa dạng
Evictions → > 0 liên tục: hết RAM, nâng node hoặc giảm TTL/giảm data
DatabaseMemoryUsagePercentage → > 80%: cảnh báo
EngineCPUUtilization → Redis single-thread, > 70% là nghẽn
CurrConnections → Lettuce pool × số instance; tăng bất thường = leak
ReplicationLag → replica trễ (đọc từ reader endpoint có thể stale)
aws cloudwatch put-metric-alarm --profile shop-lab --alarm-name shop-cache-evictions \
--namespace AWS/ElastiCache --metric-name Evictions \
--dimensions Name=CacheClusterId,Value=shop-cache-001 \
--statistic Sum --period 300 --evaluation-periods 2 --threshold 100 \
--comparison-operator GreaterThanThreshold --alarm-actions $SNS_ALARM_ARN
Và trong app, Micrometer có cache.gets{result=hit|miss} cho từng cache name, xem qua Actuator/Prometheus (chương Observability).
Cho mình bài lab để tự tay làm phần này nhé.
Lab 22.9: shop-cache trên ElastiCache, Spring Cache và session store
Mục tiêu
- Tạo subnet group,
shop-cache-sgvà replication groupshop-cache(Redis 7,cache.t3.micro, 1 node, TLS) trong private subnet củashop-vpc. - Nối shop-app trên EC2 vào cache qua Lettuce; thấy
@Cacheablebiến 3 query thành 1 lần GET; đo p99 trước/sau bằngheyhoặcab. - Chuyển session sang Redis, tắt sticky session ở ALB, đăng nhập xong vẫn giữ phiên khi request rơi vào instance khác.
- Xem metric CacheHits/CacheMisses, Evictions trong CloudWatch; thử failover (phần thử thêm).
Chi phí & thời gian
- ElastiCache không có Free Tier.
cache.t3.microkhoảng 0.02 USD/giờ (kiểm tra pricing page). Lab 2 giờ ≈ 0.04 USD. Quên xóa một tháng ≈ 15 USD. - Cần EC2 app (t3.micro, Free Tier 750 giờ/tháng) và RDS
shop-db(db.t3.micro Free Tier 12 tháng) từ bài trước. ALB nếu đang chạy khoảng 0.025 USD/giờ + LCU, không Free Tier. - NAT Gateway (nếu bạn đã tạo ở bài 22.3) ~0.06 USD/giờ: lab này không cần Internet từ private subnet; có thể xóa NAT, dùng SSM qua VPC endpoint hoặc để app ở public subnet tạm.
- Thời gian: 60–90 phút (tạo cluster mất ~5–10 phút).
Chuẩn bị
shop-vpcvới 2 private subnet (bài 22.3), ít nhất 1 EC2 chạyshop-app.jarvớishop-app-sgvà role có SSM (bài 22.4),shop-dbRDS (bài 22.7). Có ALB thì tốt để test session, không có thì test bằng 2 instance và curl trực tiếp.- Profile
shop-lab, biếnAWS_REGION,ACCOUNT_ID. Quyềnelasticache:*,ec2:*SecurityGroup*,ssm:StartSession,cloudwatch:*. - Source shop-app có thêm dependency ở mục 4 bước 3; build jar mới.
Các bước
Bước 1: Subnet group + security group. Chạy block lệnh ở mục 4 bước 1. Kiểm tra:
aws elasticache describe-cache-subnet-groups --profile shop-lab --cache-subnet-group-name shop-cache-subnets \
--query 'CacheSubnetGroups[0].Subnets[].SubnetAvailabilityZone.Name' # ["ap-southeast-1a","ap-southeast-1b"]
Console: ElastiCache → Subnet groups → Create; EC2 → Security Groups → Create, inbound Custom TCP 6379, Source = sg của shop-app-sg.
Bước 2: Tạo replication group (mục 4 bước 2), chờ available (~5–10 phút), lấy CACHE_HOST.
Console: ElastiCache → Redis OSS caches → Create → Design your own cache → Cluster cache → Cluster mode Disabled → Node type cache.t3.micro, Replicas 0 → Subnet group shop-cache-subnets → Security group shop-cache-sg → Encryption in transit → Tags.
Bước 3: Đưa host vào Parameter Store thay vì sửa user data mỗi lần:
aws ssm put-parameter --profile shop-lab --name /shop/app/cache-host --type String \
--value "$CACHE_HOST" --overwrite \
--tags Key=Project,Value=shop-app Key=Env,Value=lab 2>/dev/null || \
aws ssm put-parameter --profile shop-lab --name /shop/app/cache-host --type String --value "$CACHE_HOST" --overwrite
Role shop-app-ec2-role cần ssm:GetParameter trên arn:aws:ssm:$AWS_REGION:$ACCOUNT_ID:parameter/shop/app/*. Trong user data / systemd của app (bài 22.4):
export SHOP_CACHE_HOST=$(aws ssm get-parameter --name /shop/app/cache-host --query Parameter.Value --output text --region ap-southeast-1)
java -jar /opt/shop/shop-app.jar
Console: Systems Manager → Parameter Store → Create parameter.
Bước 4: Deploy jar mới (có Spring Cache + Session + CacheFailOpen) lên EC2, restart service. Nếu dùng ASG với launch template shop-lt: tạo version mới, start-instance-refresh.
Bước 5: Kiểm tra từ trong VPC qua SSM:
INSTANCE_ID=$(aws ec2 describe-instances --profile shop-lab \
--filters Name=tag:Project,Values=shop-app Name=instance-state-name,Values=running \
--query 'Reservations[0].Instances[0].InstanceId' --output text)
aws ssm start-session --profile shop-lab --target $INSTANCE_ID
# trong session:
sudo dnf install -y redis6 >/dev/null
H=$(aws ssm get-parameter --name /shop/app/cache-host --query Parameter.Value --output text --region ap-southeast-1)
redis-cli -h $H --tls PING # PONG
time curl -s localhost:8080/products/42 -o /dev/null # lần 1 ~50 ms (miss)
time curl -s localhost:8080/products/42 -o /dev/null # lần 2 ~3 ms (hit)
redis-cli -h $H --tls --scan --pattern 'shop:*' # shop:product::42
redis-cli -h $H --tls TTL 'shop:product::42' # ≤ 300
Bước 6: Session. Đăng nhập qua ALB (hoặc curl với cookie jar vào instance 1, rồi gửi cookie đó vào instance 2):
ALB=$(aws elbv2 describe-load-balancers --profile shop-lab --names shop-alb --query 'LoadBalancers[0].DNSName' --output text)
curl -s -c cj.txt -X POST "http://$ALB/login" -d 'username=demo&password=***' -o /dev/null
for i in 1 2 3 4 5 6; do curl -s -b cj.txt "http://$ALB/api/me" | jq -r '.instance + " " + .user'; done
# i-0abc... demo
# i-0def... demo ← instance khác, vẫn nhận ra user → session trong Redis
redis-cli -h $H --tls --scan --pattern 'shop:session:*' | head
Endpoint /api/me trả về instanceId (đọc từ IMDS hoặc hostname) và username để thấy rõ request đổi instance.
Kiểm tra kết quả
# 1. Hit rate trong CloudWatch (có độ trễ ~1–5 phút)
for m in CacheHits CacheMisses; do
aws cloudwatch get-metric-statistics --profile shop-lab --namespace AWS/ElastiCache \
--metric-name $m --dimensions Name=CacheClusterId,Value=shop-cache-001 \
--start-time $(date -u -d '-30 min' +%FT%TZ) --end-time $(date -u +%FT%TZ) \
--period 300 --statistics Sum --query 'Datapoints[].Sum' --output text; done
# 2. Tải: trong EC2, 200 request đồng thời vào trang sản phẩm; so CPU RDS trước/sau bật cache
hey -z 30s -c 50 http://localhost:8080/products/42 # hoặc ab -t 30 -c 50
aws cloudwatch get-metric-statistics --profile shop-lab --namespace AWS/RDS --metric-name CPUUtilization \
--dimensions Name=DBInstanceIdentifier,Value=shop-db --statistics Average --period 60 \
--start-time $(date -u -d '-15 min' +%FT%TZ) --end-time $(date -u +%FT%TZ) --query 'Datapoints[].Average'
# 3. Evict từ admin: xóa key rồi gọi lại → miss một lần, hit tiếp
redis-cli -h $H --tls DEL 'shop:product::42'
Mong đợi: p99 giảm từ hàng chục ms xuống vài ms; CPU RDS giảm rõ (với 50 concurrent, từ ~60% xuống dưới 10%); hit rate > 90% sau phút đầu.
Thử thêm
- Failover: thêm replica và bật Multi-AZ:
aws elasticache increase-replica-count --replication-group-id shop-cache --new-replica-count 1 --apply-immediatelyrồimodify-replication-group --multi-az-enabled --automatic-failover-enabled --apply-immediately; sau đóaws elasticache test-failover --replication-group-id shop-cache --node-group-id 0001. Trong lúc đó chạy vòng lặp curl: app vẫn trả 200 (fail-open, đọc DB) trong ~30–60 giây, rồi hit lại. Chi phí gấp đôi; giảm replica về 0 sau khi thử. - Tắt fail-open (bỏ
CacheFailOpen) và test failover lại: thấy 500 → hiểu vì sao cần error handler. - Memcached: tạo
aws elasticache create-cache-cluster --cache-cluster-id shop-memcached --engine memcached --cache-node-type cache.t3.micro --num-cache-nodes 1 ..., so sánh: không có replica, không có backup trong Console. Xóa ngay. - Parameter group: tạo group với
maxmemory-policy=allkeys-lru, gắn vào cluster, nhồi data vượt 500 MB bằngredis-benchmark -h $H --tls -t set -n 2000000 -d 1024, xemEvictionstăng. - Snapshot:
aws elasticache create-snapshot --replication-group-id shop-cache --snapshot-name shop-cache-s1; xem tốn S3-equivalent storage; xóa snapshot.
Dọn tài nguyên
# 1. Xóa alarm (nếu tạo)
aws cloudwatch delete-alarms --profile shop-lab --alarm-names shop-cache-evictions 2>/dev/null
# 2. Xóa replication group (và Memcached nếu có) — đây là thứ TỐN TIỀN THEO GIỜ
aws elasticache delete-replication-group --profile shop-lab --replication-group-id shop-cache
aws elasticache delete-cache-cluster --profile shop-lab --cache-cluster-id shop-memcached 2>/dev/null
aws elasticache wait replication-group-deleted --profile shop-lab --replication-group-id shop-cache
# 3. Snapshot thủ công (tính tiền storage)
aws elasticache delete-snapshot --profile shop-lab --snapshot-name shop-cache-s1 2>/dev/null
# 4. Subnet group, parameter group, SG (SG phải xóa sau cluster)
aws elasticache delete-cache-subnet-group --profile shop-lab --cache-subnet-group-name shop-cache-subnets
aws ec2 delete-security-group --profile shop-lab --group-id $CACHE_SG
# 5. Parameter
aws ssm delete-parameter --profile shop-lab --name /shop/app/cache-host
# 6. Kiểm tra
aws elasticache describe-replication-groups --profile shop-lab --query 'ReplicationGroups[].ReplicationGroupId' # []
Thứ tốn tiền nếu quên: ElastiCache node (mọi loại, theo giờ), snapshot thủ công, và ALB/NAT/RDS từ các bài trước nếu bạn chưa tắt.
Phỏng vấn và đề thi SAA hỏi về ElastiCache và caching thì sao?
Q1. Vì sao dùng ElastiCache thay vì tự cài Redis trên EC2 hay dùng cache trong JVM?
🇻🇳 Tiếng Việt:
Cache trong JVM như Caffeine không chia sẻ được giữa nhiều instance sau ALB, mỗi instance một bản, invalidation phức tạp và session không dùng chung được. Tự cài Redis trên EC2 thì mình phải lo patch, failover, backup, Multi-AZ. ElastiCache cho Redis managed trong VPC, Multi-AZ auto failover, encryption, backup, metric CloudWatch, mình chỉ cần cấu hình subnet group, security group và endpoint. Với shop-app em dùng nó cho cache sản phẩm và Spring Session, giảm tải RDS hơn 90% và bỏ được sticky session.
🇬🇧 English:
An in-JVM cache like Caffeine is not shared across instances behind the ALB: each node has its own copy, invalidation is hard and sessions cannot be shared. Self-managed Redis on EC2 means we own patching, failover, backups and Multi-AZ. ElastiCache gives managed Redis inside the VPC with Multi-AZ automatic failover, encryption, backups and CloudWatch metrics; we only configure a subnet group, a security group and the endpoint. In shop-app I use it for the product cache and Spring Session, which cut RDS load by over 90% and let us drop sticky sessions.
Q2. Lazy loading và write-through khác nhau thế nào, bạn dùng gì cho shop-app?
🇻🇳 Tiếng Việt:
Lazy loading, hay cache-aside, chỉ nạp vào cache khi có miss: cache gọn, chỉ chứa thứ được đọc, nhưng lần đầu chậm và có thể stale cho đến khi hết TTL. Write-through ghi cache ngay khi ghi DB: dữ liệu luôn mới nhưng mỗi write tốn thêm và cache đầy thứ không ai đọc. Em dùng cache-aside với TTL 5 phút cho sản phẩm, kèm evict qua event khi admin đổi giá, và write-through cho dữ liệu phải đúng ngay như giỏ hàng nếu để trong Redis.
🇬🇧 English:
Lazy loading, or cache-aside, only populates the cache on a miss: the cache stays small and holds only what is read, but the first read is slow and data can be stale until the TTL expires. Write-through updates the cache whenever the database is written: data is always fresh, but every write costs more and the cache fills with items nobody reads. I use cache-aside with a five-minute TTL for products, plus event-driven eviction when an admin changes a price, and write-through for data that must be correct immediately, such as a cart kept in Redis.
Q3. Redis chết thì shop-app ra sao? Bạn thiết kế thế nào?
🇻🇳 Tiếng Việt:
Cache là tối ưu, không phải nguồn sự thật, nên app phải fail-open: ghi đè CacheErrorHandler để lỗi Redis được coi như miss và đọc DB, có log và metric. Prod bật Multi-AZ với auto failover, Lettuce tự resolve lại primary endpoint sau khoảng một phút. Session thì khác vì mất Redis là user bị logout, nên session bắt buộc Multi-AZ, và nếu được em chuyển sang JWT stateless để Redis chỉ còn giữ refresh token.
🇬🇧 English:
The cache is an optimization, not the source of truth, so the app must fail open: I override CacheErrorHandler so Redis errors are treated as misses and we read the database, with logs and metrics. In production we enable Multi-AZ with automatic failover, and Lettuce re-resolves the primary endpoint within about a minute. Sessions are different because losing Redis logs users out, so the session store must be Multi-AZ, and where possible I move to stateless JWTs so Redis only holds refresh tokens.
Q4. Khi nào chọn Memcached thay Redis? Khi nào cluster mode enabled?
🇻🇳 Tiếng Việt:
Memcached khi chỉ cần cache object đơn giản, muốn multi-thread và scale ngang bằng cách thêm node, không cần persistence, replication hay data type phức tạp. Redis khi cần sorted set, pub/sub, Lua, HA và backup. Cluster mode enabled khi dữ liệu vượt RAM một node hoặc throughput ghi vượt một primary, vì nó chia shard theo hash slot; đổi lại client phải cluster-aware và transaction chỉ trong cùng slot.
🇬🇧 English:
Memcached when we only need a simple object cache, want multithreading and horizontal scaling by adding nodes, and need no persistence, replication or rich data types. Redis when we need sorted sets, pub/sub, Lua, HA and backups. Cluster mode enabled when the dataset exceeds one node's memory or write throughput exceeds a single primary, since it shards by hash slot; the trade-off is a cluster-aware client and transactions limited to one slot.
Góc thi SAA-C03
Câu 1: Một ứng dụng web chạy trên Auto Scaling group sau ALB. Người dùng bị đăng xuất mỗi khi một instance bị scale-in. Công ty muốn session tồn tại qua các instance với độ trễ thấp nhất và khả năng chịu lỗi. Chọn gì?
A. Bật sticky session trên ALB.
B. Lưu session trong Amazon ElastiCache for Redis với Multi-AZ.
C. Lưu session trong bảng RDS Multi-AZ.
D. Lưu session vào EFS gắn chung cho các instance.
Đáp án: B. Session store dùng chung, in-memory, sub-millisecond, Multi-AZ failover. A: sticky session vẫn mất phiên khi instance scale-in/chết (chính là triệu chứng của đề). C: RDS được nhưng độ trễ cao hơn và tốn connection; đề nhấn "lowest latency". D: EFS là file system, không phù hợp cho session có hàng nghìn ghi/giây. Từ khóa: "session state", "low latency", "fault tolerant" → ElastiCache Redis Multi-AZ (DynamoDB cũng chấp nhận được nếu có trong lựa chọn và nhấn "serverless").
Câu 2: Một trang tin đọc nhiều dữ liệu từ RDS MySQL; cùng một bài viết được đọc hàng nghìn lần trước khi đổi. Database CPU cao. Giải pháp nào giảm tải RDS với ít thay đổi kiến trúc nhất và cho phép hiển thị dữ liệu cũ tối đa 60 giây?
A. Thêm RDS Read Replica và chuyển mọi đọc sang replica.
B. Tăng kích thước instance RDS.
C. Thêm ElastiCache, dùng lazy loading với TTL 60 giây.
D. Chuyển dữ liệu sang DynamoDB với DAX.
Đáp án: C. "Same data read many times", "stale up to 60 seconds acceptable" → cache với TTL; lazy loading chỉ cache thứ được đọc. A giảm tải nhưng vẫn chạy mọi query, phải tách read/write trong app và có replica lag; không tận dụng đặc tính đọc lặp. B tốn tiền, tuyến tính, không giải quyết gốc. D là thay database, thay đổi kiến trúc lớn nhất.
Câu 3: Một game online cần bảng xếp hạng thời gian thực top 100 người chơi, cập nhật hàng nghìn lần mỗi giây, truy vấn liên tục. Dịch vụ nào phù hợp nhất?
A. Amazon ElastiCache for Memcached.
B. Amazon ElastiCache for Redis dùng sorted set.
C. Amazon RDS for PostgreSQL với index trên cột điểm.
D. Amazon DynamoDB với GSI trên điểm số.
Đáp án: B. "Leaderboard", "real-time ranking" → Redis sorted set (ZADD, ZREVRANGE) O(log N), in-memory. A Memcached không có sorted set. C RDS ORDER BY score LIMIT 100 với hàng nghìn update/giây sẽ nghẽn. D GSI trên điểm số là hot partition kinh điển (PK có cardinality thấp) và Query theo sort key của GSI cần partition key cố định; không phải mẫu đúng cho ranking toàn cục.
Cách nhớ ngắn gọn
- ElastiCache = Redis/Valkey/Memcached managed, trong VPC, không public, không Free Tier, test từ EC2 qua SSM.
- Lazy loading: nạp khi miss, có stale; write-through: ghi cùng DB, luôn mới nhưng tốn; luôn cộng TTL.
- Redis = data type + HA + persistence; Memcached = đơn giản, multi-thread, không HA.
- Cache phải fail-open; session phải Multi-AZ; hit rate < 80% là thiết kế key/TTL có vấn đề.
- Cache cho DynamoDB → DAX; cache HTTP ở edge → CloudFront; cache DB/session/leaderboard → ElastiCache.
| Từ khóa trong đề | Nghĩ tới |
|---|---|
| session state, shared across instances, low latency | ElastiCache Redis (Multi-AZ) hoặc DynamoDB |
| leaderboard, sorted set, pub/sub, complex data types | ElastiCache Redis |
| simple object cache, multithreaded, no persistence | ElastiCache Memcached |
| reduce read load on RDS, same data read repeatedly, stale acceptable | ElastiCache lazy loading + TTL |
| data must always be current in cache | Write-through |
| dataset larger than one node, scale writes | Redis cluster mode enabled |
| no capacity planning for cache | ElastiCache Serverless |
| cross-region read replica of Redis | Global Datastore |
| microsecond reads for DynamoDB | DAX, không phải ElastiCache |
Tự kiểm tra
Vì sao redis-cli từ laptop không nối được ElastiCache dù SG mở 0.0.0.0/0?
ElastiCache chỉ có IP private trong VPC, không có public endpoint. Phải vào từ EC2/ECS trong VPC (SSM Session Manager hoặc port forwarding), và SG đúng là chỉ nhận từ shop-app-sg.
shop-cache-sg nên có rule inbound gì?
TCP 6379 với source là security group shop-app-sg (tham chiếu SG, không phải CIDR). Không mở ra Internet.
Khác nhau giữa primary endpoint và reader endpoint?
Primary endpoint luôn trỏ tới node primary hiện tại (ghi và đọc), được AWS cập nhật khi failover. Reader endpoint phân tải đọc round-robin lên replica, dữ liệu có thể trễ vài ms (eventually consistent).
Vì sao cần CacheErrorHandler tùy biến khi dùng Spring Cache với Redis?
Mặc định lỗi kết nối Redis ném exception làm request 500. Cache chỉ là tối ưu, nên nên coi lỗi là miss và đọc DB (fail-open), đồng thời log/metric để biết Redis đang hỏng.
Metric nào trên ElastiCache cho biết node đang thiếu RAM?
Evictions > 0 liên tục và DatabaseMemoryUsagePercentage cao. Giải pháp: nâng node, giảm TTL/data, hoặc kiểm tra maxmemory-policy.