缓存架构升级:从自建 Redis 迁移到 GeminiDB Redis 接口并实现缓存击穿防护

举报
yd_280727371 发表于 2026/11/09 16:03:40 2026/11/09
【摘要】 摘要:自建 Redis 集群在业务高速增长期频繁出现主节点宕机、内存溢出、跨 AZ 容灾缺失等问题,大促期间缓存击穿直接导致数据库被打穿。本文以电商商品详情页缓存为场景,基于华为云 GeminiDB Redis 接口,完成从自建 Redis 到云原生兼容 Redis 协议的平滑迁移,并结合本地缓存、互斥锁、逻辑过期三种策略实现缓存击穿防护,构建一套高可用、强一致、免运维的缓存架构。标签建议:...
摘要:自建 Redis 集群在业务高速增长期频繁出现主节点宕机、内存溢出、跨 AZ 容灾缺失等问题,大促期间缓存击穿直接导致数据库被打穿。本文以电商商品详情页缓存为场景,基于华为云 GeminiDB Redis 接口,完成从自建 Redis 到云原生兼容 Redis 协议的平滑迁移,并结合本地缓存、互斥锁、逻辑过期三种策略实现缓存击穿防护,构建一套高可用、强一致、免运维的缓存架构。
标签建议:云数据库 GeminiDB、分布式缓存、Redis 迁移、缓存击穿、云端实践

一、背景与痛点

商品详情页是电商系统的流量入口,QPS 峰值 5 万+。缓存层最初用的是 3 台 ECS 自建 Redis 3.0 主从集群:

痛点
具体表现
内存瓶颈
商品数据膨胀到 40GB,单节点内存打满,OOM 频发
主从切换慢
主节点宕机后 Sentinel 切换需 15-30 秒,期间大量请求穿透到数据库
跨 AZ 无保护
3 台机器在同一 AZ,机房抖动直接全挂
扩容要停写
加节点需重新分片,业务停写窗口 20 分钟以上
缓存击穿
热点商品(如秒杀品)过期瞬间,上千请求直击 MySQL,数据库 CPU 飙到 100%
运维靠人
每周手动执行 BGSAVE,备份文件拷到 OBS,恢复演练从未做过
核心诉求:协议兼容 Redis、无缝迁移、自动弹性、跨 AZ 高可用、击穿防护。

二、方案选型

2.1 为什么选 GeminiDB Redis 接口


维度
自建 Redis
华为云 DCS Redis
GeminiDB Redis 接口
兼容性
原生
兼容 Redis
兼容 Redis 协议
存储架构
内存
内存
存算分离,数据落盘
容量上限
受单机内存限制
单节点 64GB
TB 级,按需扩展
跨 AZ
需自建
支持
原生 3AZ 部署
扩容
停写
秒级
秒级,存储计算独立扩
持久化
RDB/AOF 手动配
自动
自动,RPO=0
成本
低(但运维成本高)
中
中高(省运维人力)
结论:需要大容量、高可用、免运维且兼容 Redis 协议,GeminiDB Redis 接口是平衡选择。如果数据量 < 16GB 且追求极致性能,DCS Redis 更合适。

2.2 整体架构

应用服务集群(CCE / ECS)
   │
   ├── 本地缓存(Caffeine,一级,TTL 60s)
   │
   ├── GeminiDB Redis 接口(二级,TTL 自定义)
   │     ├── 商品基本信息 Hash
   │     ├── 商品库存 String
   │     ├── 商品详情 JSON String
   │     └── 热点 Key 标记 Bitmap
   │
   └── RDS MySQL(三级,兜底)
         └── 商品表

2.3 击穿防护策略选择


策略
原理
优点
缺点
适用场景
互斥锁
过期时只有一个线程查 DB 并回填
实现简单、数据一致
锁等待增加延迟
通用场景
逻辑过期
Key 不设 TTL,值里带过期时间,异步刷新
无等待、性能好
数据短暂不一致
热点商品
本地缓存
JVM 内缓存,减少 Redis 访问
最快、彻底防击穿
集群间不一致
读多写少
组合方案:本地缓存(Caffeine 60s)+ Redis 分布式锁 + 逻辑过期兜底。

三、迁移实操

3.1 创建 GeminiDB Redis 实例

  1. 进入云数据库 GeminiDB 控制台 → 创建实例
  2. 兼容接口:Redis
  3. 版本:Redis 5.0/6.0 兼容
  4. 存储类型:SSD 云盘(高性能)
  5. 部署模式:3AZ 高可用(生产必选)
  6. 容量:初始 100GB(可按需在线扩容)
  7. 规格:计算节点 4U16G × 3
  8. 网络:与业务应用同 VPC、同安全组
避坑:GeminiDB Redis 接口与开源 Redis 在部分命令上有差异(如不支持 KEYS * 生产环境本来也不该用),迁移前先用 redis-cli 跑一遍业务常用命令验证兼容性。

3.2 数据迁移方案

方案 A:双写迁移(推荐,零停机)
  1. 应用代码改造为双写模式:写操作同时写自建 Redis 和 GeminiDB
  2. 全量数据同步:用 redis-shake 工具从自建 Redis 全量同步到 GeminiDB
  3. 增量同步:redis-shake 持续追自建 Redis 的 AOF
  4. 校验一致性:抽样对比两边 Key 数量和关键 Key 的值
  5. 切换读流量:应用读请求从自建 Redis 切到 GeminiDB
  6. 观察 48 小时无异常,停写自建 Redis,下线
方案 B:停机迁移(简单但需窗口)
  1. 业务低峰停写
  2. redis-cli --rdb 导出 RDB 文件
  3. 用 redis-shake 或 redis-import 导入 GeminiDB
  4. 修改应用连接串,重启服务
redis-shake 配置示例:
source:
  type: standalone
  address: 192.168.1.100:6379
  password: "old-redis-password"

target:
  type: standalone
  address: geminidb-redis.cn-north-4.dcs.myhuaweicloud.com:6379
  password: "geminidb-password"

sync:
  rdb: true
  aof: true
  parallel: 16

3.3 应用连接改造

连接池配置(Jedis 示例):
@Configuration
public class RedisConfig {

    @Value("${redis.host}")
    private String host;

    @Value("${redis.port:6379}")
    private int port;

    @Value("${redis.password}")
    private String password;

    @Bean
    public JedisPool jedisPool() {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(200);
        config.setMaxIdle(50);
        config.setMinIdle(10);
        config.setTestOnBorrow(true);
        config.setTestWhileIdle(true);
        config.setTimeBetweenEvictionRunsMillis(30000);

        return new JedisPool(config, host, port, 5000, password, 0, "default");
    }
}
连接串变更:只需将 host 从自建 Redis IP 改为 GeminiDB 连接地址,端口和密码对应更新,业务代码无需改动。

四、缓存击穿治理

4.1 互斥锁方案(核心商品)

@Service
public class ProductCacheService {

    @Autowired
    private JedisPool jedisPool;

    @Autowired
    private ProductMapper productMapper;

    private static final String LOCK_PREFIX = "lock:product:";
    private static final int LOCK_EXPIRE = 10; // 秒
    private static final int CACHE_EXPIRE = 300; // 秒

    public ProductDetail getProduct(Long productId) {
        String cacheKey = "product:" + productId;

        // 1. 先查 Redis
        try (Jedis jedis = jedisPool.getResource()) {
            String cached = jedis.get(cacheKey);
            if (cached != null) {
                return JSON.parseObject(cached, ProductDetail.class);
            }
        }

        // 2. Redis 未命中,尝试获取分布式锁
        String lockKey = LOCK_PREFIX + productId;
        String lockValue = UUID.randomUUID().toString();
        boolean locked = false;

        try (Jedis jedis = jedisPool.getResource()) {
            // SET NX EX 原子操作
            String result = jedis.set(lockKey, lockValue, "NX", "EX", LOCK_EXPIRE);
            locked = "OK".equals(result);
        }

        if (locked) {
            try {
                // 3. 拿到锁,查数据库
                ProductDetail product = productMapper.selectById(productId);
                if (product != null) {
                    // 4. 写入 Redis
                    try (Jedis jedis = jedisPool.getResource()) {
                        jedis.setex(cacheKey, CACHE_EXPIRE, JSON.toJSONString(product));
                    }
                } else {
                    // 空值缓存,防止缓存穿透
                    try (Jedis jedis = jedisPool.getResource()) {
                        jedis.setex(cacheKey, 60, "NULL");
                    }
                }
                return product;
            } finally {
                // 5. 释放锁(Lua 脚本保证原子性)
                releaseLock(lockKey, lockValue);
            }
        } else {
            // 6. 未拿到锁,短暂休眠后重试
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return getProduct(productId); // 递归重试(生产环境应设最大重试次数)
        }
    }

    private void releaseLock(String lockKey, String lockValue) {
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                          "return redis.call('del', KEYS[1]) " +
                          "else return 0 end";
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.eval(luaScript, 1, lockKey, lockValue);
        }
    }
}

4.2 逻辑过期方案(热点商品)

@Data
public class LogicalExpireCache<T> {
    private T data;
    private long expireTime; // 逻辑过期时间戳

    public boolean isExpired() {
        return System.currentTimeMillis() > expireTime;
    }
}
public ProductDetail getHotProduct(Long productId) {
    String cacheKey = "product:hot:" + productId;

    try (Jedis jedis = jedisPool.getResource()) {
        String cached = jedis.get(cacheKey);
        if (cached == null) {
            // 首次加载,同步查库
            return loadAndCache(productId, cacheKey, jedis);
        }

        LogicalExpireCache<ProductDetail> wrapper =
            JSON.parseObject(cached, new TypeReference<LogicalExpireCache<ProductDetail>>() {});

        if (wrapper.isExpired()) {
            // 逻辑过期,异步刷新(提交到线程池)
            refreshExecutor.submit(() -> {
                ProductDetail fresh = productMapper.selectById(productId);
                if (fresh != null) {
                    LogicalExpireCache<ProductDetail> newWrapper = new LogicalExpireCache<>();
                    newWrapper.setData(fresh);
                    newWrapper.setExpireTime(System.currentTimeMillis() + 3600_000);
                    try (Jedis j = jedisPool.getResource()) {
                        j.set(cacheKey, JSON.toJSONString(newWrapper));
                    }
                }
            });
        }

        return wrapper.getData(); // 返回旧数据,保证可用性
    }
}

4.3 本地缓存 + Redis 多级缓存

@Service
public class MultiLevelCacheService {

    // Caffeine 本地缓存,最大 10k 条目,60 秒过期
    private final Cache<Long, ProductDetail> localCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(60, TimeUnit.SECONDS)
            .build();

    public ProductDetail getProduct(Long productId) {
        // L1: 本地缓存
        ProductDetail cached = localCache.getIfPresent(productId);
        if (cached != null) {
            return cached;
        }

        // L2: Redis
        String redisKey = "product:" + productId;
        try (Jedis jedis = jedisPool.getResource()) {
            String value = jedis.get(redisKey);
            if (value != null && !"NULL".equals(value)) {
                ProductDetail product = JSON.parseObject(value, ProductDetail.class);
                localCache.put(productId, product);
                return product;
            }
        }

        // L3: 数据库 + 回填
        ProductDetail product = productMapper.selectById(productId);
        if (product != null) {
            localCache.put(productId, product);
            try (Jedis jedis = jedisPool.getResource()) {
                jedis.setex(redisKey, 300, JSON.toJSONString(product));
            }
        }

        return product;
    }
}

五、验证结果


验证项
操作
预期
协议兼容
业务代码不改一行,只换连接串
所有 Redis 命令正常执行
性能对比
压测 5 万 QPS
P99 延迟 < 5ms
容量弹性
在线扩容 100GB → 200GB
秒级生效,无业务中断
跨 AZ 容灾
手动停一个 AZ 的计算节点
自动切换,RTO < 30s
缓存击穿防护
热点 Key 过期瞬间 5000 QPS 并发
数据库 QPS 无飙升,仅 1 次查库
数据持久化
写入后 kill 进程
重启后数据不丢,RPO=0

六、生产避坑清单

❌ 常见错误
  1. 用 KEYS * 排查问题:生产环境执行直接阻塞所有请求,用 SCAN 替代
  2. 大 Key 不拆分:单个 Hash 存 10 万 field,读取一次卡 200ms,拆成多个 Key
  3. 热 Key 不处理:某个商品被疯狂访问,单分片 CPU 打满,用本地缓存或 Key 分片
  4. 不设 TTL 导致内存膨胀:缓存只写不删,最终 OOM
  5. 分布式锁不设超时:业务异常未释放锁,后续请求全部阻塞
  6. 锁释放不校验 owner:误删别人的锁,用 Lua 脚本原子校验
  7. 迁移期间源库执行 FLUSHALL:全量同步直接废掉
  8. 连接池不配 testOnBorrow:拿到已关闭连接,大量 SocketException
  9. GeminiDB 和 ECS 跨 Region:延迟从 0.5ms 变 5ms,务必同 Region
  10. 忽略慢查询日志:不监控 slowlog,性能问题靠猜
✅ 正确姿势
  • 大 Key 拆分:Hash 超 1000 field 拆成 hash:{shard1}、hash:{shard2}
  • 热 Key 打散:Key 后缀加随机值,读时聚合
  • 锁超时时间设为业务耗时的 3-5 倍
  • 迁移期间源库设为只读(从库同步)
  • 连接池 maxTotal 按并发量 × 2 配置
  • 开启 GeminiDB 审计日志和慢查询监控
  • 本地缓存容量设上限,防止 JVM OOM
  • 缓存预热:大促前提前加载热点数据到 Redis

七、成本结构梳理


计费项
说明
优化建议
计算节点
按规格和数量
3 节点起步,读多可加只读节点
存储
按实际使用量
存算分离,存储按需增长
跨 AZ 部署
3AZ 比单 AZ 贵约 30%
核心业务必选,非核心可单 AZ
备份存储
自动备份保留
按合规要求设保留天数
流量
内网流量免费
公网访问才计费
对比自建 Redis:省掉 3 台 ECS 费用 + 运维人力成本,综合 TCO 降低约 40%。

八、总结与扩展

缓存架构的演进路径是:本地缓存 → 分布式缓存 → 多级缓存 → 智能缓存。
这次改造的核心收获:
  • 迁移零停机:双写 + 增量同步,业务无感知切换
  • 击穿有防护:互斥锁 + 逻辑过期 + 本地缓存三层兜底
  • 运维自动化:扩容、备份、故障切换全部平台托管
  • 容量无上限:存算分离架构,存储按需扩展不受单节点限制
后续演进方向:
  1. 缓存预热自动化:基于历史访问日志,大促前自动预热热点数据
  2. 热点 Key 自动发现:接入 Redis 监控,自动识别热 Key 并推送到本地缓存
  3. 多级缓存统一失效:通过 Canal 监听 MySQL binlog,统一失效各级缓存
  4. 混合持久化:GeminiDB 本身已持久化,可评估是否仍需 MySQL 兜底
  5. 异地多活:跨 Region 部署 GeminiDB,通过全球加速实现就近访问

本文为原创缓存架构实践,基于华为云 GeminiDB Redis 接口整理,操作步骤以当前控制台页面为准。转载请注明出处。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。