ElastiCache (Redis/Memcached), chiến lược cache, session store trên AWS

6 câu hỏi~45 phútTừ khóa: ElastiCache, Redis, Valkey, Memcached, cache-aside, write-through, lazy loading, TTL, cluster mode, Multi-AZ, Spring Cache, Lettuce, Spring Session, DAX, SAA: Domain 1 Performance

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 đề

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ĩaTrên shop-app
EngineRedis OSS, Valkey (fork mã mở của Redis, AWS khuyến khích, rẻ hơn khoảng 20%), MemcachedValkey/Redis
NodeMột instance cache, ví dụ cache.t3.micro (~0.5 GB), cache.r7g.largeLab: cache.t3.micro
Replication group (cluster, Redis)1 primary + 0–5 replica, Multi-AZ, auto failovershop-cache
Cluster mode disabledMộ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 enabledNhiều shard (tối đa 500), data chia theo hash slot, scale ghi ngang; tối đa ~hàng trăm TBKhi > vài chục GB hoặc write nặng
Multi-AZ với auto failoverReplica ở 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
ServerlessKhông chọn node, trả theo GB-giờ data và ECPU; scale tự độngWorkload lạ, không muốn đo
Subnet groupDanh sách private subnet cache được đặt vàoshop-cache-subnets = shop-private-a/b
Parameter groupmaxmemory-policy, notify-keyspace-events…allkeys-lru cho cache; volatile-lru nếu trộn session
Global DatastoreReplicate Redis cross-region (chỉ đọc ở region phụ)Khi mở shop ở Nhật
EncryptionAt-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 / ValkeyMemcached
Data typeString, hash, list, set, sorted set, stream, geo, pub/sub, LuaChỉ string/object
Replication, Multi-AZ, failoverCóKhông (node chết là mất data node đó)
Persistence, backup/restoreCó (RDB snapshot)Không
ScaleReplica (đọc) + cluster mode (shard ghi)Multi-thread, thêm node (client hash)
Encryption, AUTH/RBACCó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"
ElastiCacheDAX (bài 22.8)CloudFront (bài 22.10)Caffeine (in-process)
Cache gìBất kỳ: object, query result, session, counterChỉ DynamoDB item/queryHTTP response ở edgeObject trong JVM
Chia sẻ giữa instanceCóCóCó (toàn cầu)Không
Độ trễ< 1 ms trong VPCmicro-giâyvài ms tới usernano-giây
CodeTự viết hoặc Spring CacheĐổi clientKhông đổi code, chỉ header Cache-ControlSpring Cache
Dùng khiRDS/DB nặng đọc, session, rate limitDynamoDB đọc nặngStatic, API GET công khaiConfig, lookup nhỏ, không đổi
Cluster mode disabledCluster mode enabledServerless
Dữ liệuMột shard, ≤ RAM một node (tới ~hàng trăm GB với r7g)Nhiều shard, chia theo hash slotTự scale
Scale ghiDọc (đổi node type)Ngang (thêm shard, online resharding)Tự động
ClientMột endpointCluster-aware client (Lettuce hỗ trợ); MULTI/Lua chỉ cùng slotMộ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

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 ánChi 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.mediumKhoảng gấp 4 lần micro (~vài chục USD/tháng thêm); nhân đôi nữa nếu Multi-AZCPU 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ữaChia 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ángHit 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ênNhư trên + failover ~1 phút
ElastiCache ServerlessTố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 sizeHợp khi tải thất thường, không hợp lab nhỏ

Ba lý do cache thắng:

  1. 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á.
  2. 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.
  3. 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.

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ứngNguyên nhânSửa
PING treo từ EC2 trong VPCSG cache không nhận từ SG app, hoặc subnet group ở VPC khácKiểm tra --source-group, subnet group
Connection reset ngay lập tứcCluster bật TLS, client không dùng --tls / ssl.enabledBật TLS phía client; SNI khi port-forward
NOAUTH Authentication requiredBật AUTH token / RBAC mà app thiếu password/usernamespring.data.redis.password từ Secrets Manager; RBAC thì thêm username
MOVED 1234 10.0.12.5:6379Cluster mode enabled nhưng client dùng standaloneLettuce: 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

Chi phí & thời gian

Chuẩn bị

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

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

Từ khóa trong đềNghĩ tới
session state, shared across instances, low latencyElastiCache Redis (Multi-AZ) hoặc DynamoDB
leaderboard, sorted set, pub/sub, complex data typesElastiCache Redis
simple object cache, multithreaded, no persistenceElastiCache Memcached
reduce read load on RDS, same data read repeatedly, stale acceptableElastiCache lazy loading + TTL
data must always be current in cacheWrite-through
dataset larger than one node, scale writesRedis cluster mode enabled
no capacity planning for cacheElastiCache Serverless
cross-region read replica of RedisGlobal Datastore
microsecond reads for DynamoDBDAX, 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.