分布式微服务系统架构第168集:不要让“百万用户”直连 Redis

加群联系作者vx:xiaoda0423

仓库地址:https://webvueblog.github.io/JavaPlusDoc/

https://1024bat.cn/

https://github.com/webVueBlog/fastapi_plus

https://webvueblog.github.io/JavaPlusDoc/

点击勘误issues,哪吒感谢大家的阅读


1) 架构选型(怎么挑)

  • Sentinel 主从(HA)

    • 1 主 N 从,故障自动切主,不分片

    • 读多写少、中小规模、业务多键操作多时优先。

  • Cluster 分片(HA + 扩容)

    • ≥3 主(每主可配从),按 slot 水平扩展

    • 高 QPS/大数据量/需要横向扩容;接受跨槽限制。

简单记:要分片就 Cluster;不分片但要高可用就 Sentinel


2) 部署要点(最小可用)

A. Sentinel(示例)

  • 拓扑:1 master + 2 replicas + 3 sentinels

  • 关键:一致的 requirepass/masterauth、Sentinel 监控 mymaster

B. Cluster(示例)

  • 拓扑:3 主 3 从 起步(6 个节点),槽位 16384 自动分配。

  • 关键:cluster-enabled yescluster-require-full-coverage no(避免单分片故障全停)。

容器/云服务直接选官方模板即可,生产务必跨可用区部署。


3) Spring 客户端配置(Lettuce)

Sentinel

spring:
  redis:
    password: ${REDIS_PWD}
    sentinel:
      master: mymaster
      nodes: r1:26379,r2:26379,r3:26379
    lettuce:
      pool:
        max-active: 200
        max-idle: 50
        min-idle: 10
    timeout: 1500ms

Cluster

spring:
  redis:
    password: ${REDIS_PWD}
    cluster:
      nodes: c1:6379,c2:6379,c3:6379,c4:6379,c5:6379,c6:6379
      max-redirects: 5
    lettuce:
      pool:
        max-active: 300
        max-idle: 80
        min-idle: 20
    timeout: 1500ms

建议:应用层统一访问 Redis,用连接池;不要让客户端/用户直连 Redis。


4) 多节点必备代码范式

4.1 Pipeline(批量合并,降 RTT)

@Autowired StringRedisTemplate srt;

public List<String> batchGet(List<String> keys) {
  List<Object> res = srt.executePipelined((RedisCallback<Object>) conn -> {
    for (String k : keys) conn.stringCommands().get(k.getBytes(StandardCharsets.UTF_8));
    return null;
  });
  return res.stream().map(o -> o == null ? null : new String((byte[]) o)).toList();
}

4.2 Lua 原子操作(Cluster/Sentinel 通用)

原子读并删(回包一次性取)

String LUA = "local v=redis.call('GET',KEYS[1]); if v then redis.call('DEL',KEYS[1]); end; return v;";
String getAndDelete(String key){
  return srt.execute((RedisCallback<String>) c -> {
    byte[] b = (byte[]) c.scriptingCommands().eval(LUA.getBytes(), ReturnType.VALUE, 1, key.getBytes());
    return b==null?null:new String(b);
  });
}

4.3 分布式锁(务必“只解自己的锁”)

String lockKey="lock:order:"+orderId, token=UUID.randomUUID().toString();
Boolean ok = srt.opsForValue().setIfAbsent(lockKey, token, Duration.ofSeconds(5));
if (Boolean.TRUE.equals(ok)) {
  try { /* do work */ }
  finally {
    String LUA_UNLOCK = "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end";
    srt.execute((RedisCallback<Object>) c -> c.scriptingCommands().eval(LUA_UNLOCK.getBytes(),
        ReturnType.INTEGER,1,lockKey.getBytes(),token.getBytes()));
  }
}

4.4 Cluster 跨键操作:hash tag 规避跨槽

多键需同槽:把公共部分放 {} ,例如:

  • SET user:{123}:base ...

  • HSET user:{123}:ext ...

  • 这样 MGET user:{123}:base user:{123}:ext 不会跨槽。


5) 热点与雪崩(多节点场景最常见问题)

  • 热 Key 复制:写 N 份 hot:k#1..N(Lua 一次写多份),读随机挑一份;写时全部更新/删除。

  • 随机过期:TTL 加 ±10% 抖动,避免同时失效。

  • 负缓存:不存在结果也缓存(短 TTL),防穿透。

  • 两级缓存:L1(Caffeine 30–60s)+ L2(Redis 5–10min),并用 Pub/Sub 做 L1 失效广播。

  • 请求合并(SingleFlight) :同 key 同时只允许一个回源,其他等待缓存填充。


6) 百万用户读(多节点扛量要点)

  • 应用横向扩容 + 连接池 + Pipeline(每批 20–100)。

  • Cluster 分片,让热度自然摊到多个主分片;或热 key 复制打散。

  • 限流/熔断/降级:Redis 慢/故障时返回旧值或空值,快速恢复。

  • 观测:监控 QPS、延时、内存、命中率、慢日志、连接数、同步延迟。


7) 运维清单(上线必配)

  • 持久化:AOF everysec + RDB 定时快照(避免重启数据空)。

  • 高可用:Sentinel/Cluster 跨 AZ;从库仅兜底读(对一致性敏感请全读主)。

  • 淘汰策略allkeys-lru 或 volatile-lru,容量打满也不至于雪崩。

  • 超时:客户端 timeout 1–2s;重试指数退避。

  • 安全:密码、最小网络暴露、TLS(云上打开)。

  • 扩容/reshard:Cluster 用 redis-cli --cluster reshard 平滑迁移槽位;Sentinel 增副本先 replicaof 再接管。


8) 常见坑

  • 把 groupId/Topic 绑主机名(那是 Kafka 的坑点,这里提醒:Redis 不要按主机名分散 key 前缀导致运维困难)。

  • 大 Value/大 Hash:拆分分片,控制单值大小;避免阻塞命令。

  • Scan 误用:Cluster 上 SCAN 只是单分片;全局扫描需遍历节点。

  • 一致性错觉:主从延迟导致读旧值;强一致读请走主或用逻辑版本校验。


结论

  • 小而稳:Sentinel 主从;大而强:Cluster 分片。

  • 客户端:统一连接池 + Pipeline + Lua 原子

  • 业务:hash tag 跨键、两级缓存、热 key 打散、随机 TTL、负缓存

  • 运维:AOF、监控、限流、降级、跨 AZ


架构思路(从外到内)

  1. 网关 / 应用层吸收流量

  • 终端 → API 网关(Nginx/Ingress)→ 应用 Pod(水平扩容)→ 连接池访问 Redis

  • 应用层做限流/熔断/降级,避免把所有请求砸到 Redis。

  • 两级缓存(L1 本地 + L2 Redis)

    • L1:应用进程内 Caffeine(纳秒级),短 TTL(几十秒)

    • L2:Redis(Cluster/哨兵),较长 TTL(1–10 分钟)

    • 更新策略:Cache Aside(写库成功→删缓存);L1 失效通过 Pub/Sub 或 Stream 做失效广播。

  • Redis 横向扩展

    • Redis Cluster(推荐):≥ 3 主 3 从;热点分散到不同分片

    • 或 读写分离:主写从读(从库 eventual consistency,非强一致读慎用)

    • 热点 Key 治理:复制/打散/预热,后面详述

  • 批量/合并

    • 同一请求中批量取MGET/Pipeline),应用侧请求合并(coalescing) ,把同秒内对同 Key 的 N 次查询合并成一次对 Redis 的查询。

  • 降级兜底

    • Redis/网络异常:直接走 L1、返回旧值、或回源 DB + 限流;必要时返回“近似值/空值 + 快速恢复”。


    热点 Key 治理(抗打爆关键)

    • 热点复制:同一个 Key 复制成多份:user:123#1#N,客户端随机读一份;写时 Lua 更新/删除全部副本

    • 哈希打散:把大 Hash 拆分:h:{user:123}:0..15;查时只取命中的分片,避免大对象+单热 key。

    • 随机过期:TTL 加随机抖动(±10%),避免雪崩。

    • 负缓存(防穿透) :不存在的结果也缓存短 TTL(如 30s)。

    • 预热:大促/高峰前把热 key 预写进 Redis/L1。


    高并发接口设计要点

    • 幂等合并:对同一个 corrId 的重复请求直接返回缓存值;服务内做“在途合并”(SingleFlight)。

    • 批量拿:把多用户多 key 合并成一次 MGET 或 Pipeline。

    • 结果压缩:值尽量小;必要时启用 CLIENT TRACKING + RESP3(缓存旁路协同,需新客户端支持)。

    • 读写隔离的选择:读延迟敏感可读从,但要接受延迟一致性;强一致就全部读主(性能换一致)。


    连接与客户端参数(Lettuce 示例)

    • 连接池:每实例几十~上百个连接足矣;不要为每请求建连接。

    • Pipeline:把 10~100 个 GET 合并,单次 RTT 取回。

    • I/O 线程(Redis 6+):开启 io-threads(只对读有效)。

    • 关键参数

      • timeout ≥ p99 RTT,maxTotal(连接池大小)≈ 实例目标QPS × p95延迟(秒) × 安全系数1.5

      • tcp-keepalivesomaxconnnet.ipv4.ip_local_port_range 合理调大

      • Redis 侧 maxclients 足够大(留给从库/运维)


    容量/拓扑粗算(示例)

    • 目标:100 万 DAU、峰值 100k QPS 读

    • 部署:应用 50 实例(2k QPS/实例),Redis Cluster 6 主 6 从

    • 分区:热 key 复制 4 份(读打散),或经业务维度自然分片

    • 连接池:每实例 100 连接,Pipeline 批量 20~50,Redis 单分片 20k–40k QPS 轻松扛


    代码示例

    1) L1+L2 二级缓存(Spring + Caffeine + Redis)

    // build.gradle
    // implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
    // implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    
    // L1 本地缓存
    Cache<String, String> l1 = Caffeine.newBuilder()
        .maximumSize(200_000)
        .expireAfterWrite(Duration.ofSeconds(45))
        .build();
    
    @Autowired StringRedisTemplate srt;
    
    // 读:先 L1,miss 则 L2,仍 miss 回源(尽量避免 DB)
    public String getUserProfile(String uid) {
        String k = "user:prof:" + uid;
        return l1.get(k, _unused -> {
            String v = srt.opsForValue().get(k);
            if (v == null) {
                v = loadFromOrigin(uid);            // 回源(谨慎加限流)
                if (v != null) srt.opsForValue().set(k, v, Duration.ofMinutes(5));
            }
            return v == null ? "" : v;
        });
    }
    
    // 写:写库成功 → 删除 L2 → 通过 Pub/Sub 通知各实例删 L1
    public void updateUserProfile(String uid, String json) {
        writeDB(uid, json);
        String k = "user:prof:" + uid;
        srt.delete(k);
        srt.convertAndSend("cache:invalidate", k);
    }
    
    // 订阅失效广播,删 L1
    @EventListener(ApplicationReadyEvent.class)
    public void sub() {
        srt.getRequiredConnectionFactory().getConnection()
           .pubSubCommands().subscribe("cache:invalidate".getBytes());
        srt.getConnectionFactory().getConnection().setPubSubListener(new RedisPubSubAdapter() {
            @Override public void onMessage(byte[] ch, byte[] msg) { l1.invalidate(new String(msg)); }
        });
    }

    2) 批量 Pipeline(把 N 次 GET 合成 1 次)

    public List<String> batchGet(List<String> keys) {
        List<Object> res = srt.executePipelined((RedisCallback<Object>) conn -> {
            for (String k : keys) conn.stringCommands().get(k.getBytes(StandardCharsets.UTF_8));
            return null;
        });
        return res.stream().map(o -> o == null ? null : new String((byte[]) o, StandardCharsets.UTF_8)).toList();
    }

    3) 热点复制 & 原子更新(Lua 更新所有副本)

    // 读:随机挑一份
    String readHot(String baseKey, int replicas) {
        int idx = ThreadLocalRandom.current().nextInt(replicas) + 1;
        return srt.opsForValue().get(baseKey + "#" + idx);
    }
    
    // 写:Lua 同步更新 N 份 + 设 TTL
    private static final String LUA_SET_ALL =
        "for i=1,ARGV[2] do redis.call('SETEX', KEYS[1]..'#'..i, tonumber(ARGV[3]), ARGV[1]); end; return 1;";
    
    public void writeHotAll(String baseKey, String val, int replicas, int ttlSec) {
        srt.execute((RedisCallback<Object>) c -> c.scriptingCommands().eval(
            LUA_SET_ALL.getBytes(StandardCharsets.UTF_8),
            ReturnType.INTEGER, 1,
            baseKey.getBytes(StandardCharsets.UTF_8),
            val.getBytes(StandardCharsets.UTF_8),
            String.valueOf(replicas).getBytes(StandardCharsets.UTF_8),
            String.valueOf(ttlSec).getBytes(StandardCharsets.UTF_8)
        ));
    }

    4) 负缓存(防穿透)

    String v = srt.opsForValue().get(k);
    if (v == null) {
      v = loadFromOriginOrNull();
      srt.opsForValue().set(k, v == null ? "__NULL__" : v, Duration.ofSeconds(30));
    }
    if ("__NULL__".equals(v)) return null;

    运维与容灾

    • AOF everysec 打开,避免进程宕机数据丢;

    • Sentinel/Cluster 跨可用区,故障自动切主;

    • 监控:命中率、内存、水位、QPS、慢查询、p99、连接数、重连次数;

    • 随机过期 与 限流 防雪崩;

    • 预案:Redis 不可用时应用降级(L1 或静态/兜底)。


    一句话总结

    • 百万用户读 Redis 的关键在于:用户不直连两级缓存热点治理批量合并Redis 集群化连接池 + Pipeline限流降级

    • 做到这些,配合合理的容量和监控,百万级并发读是可稳稳拿下的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值