架构 - 缓存集群方案

缓存集群路由策略
探讨缓存集群中Hash取模与Hash环路由方案,分析两者优劣,详解Hash环算法原理及其实现,确保高命中率与负载均衡。

缓存集群面临的问题

  • 路由:假设我们的缓存服务器有3台,每台缓存的数据是不相同的,那么我们根据key获取缓存时,该从哪台服务器获取?
  • 分压:在新增服务器时,如何让新增的服务器平均的为各服务器减压
  • 命中率:在新增服务器后,如何保证命中率尽可能的高?

解决方案一:Hash取模路由(不推荐)

上面的问题可以通过取模的方式来进行路由,如下

...
public static String getRoute(String key){
    int cacheIndex = key.hashcode() % 2;
    if(cacheIndex == 0){
        return "A缓存服务器";
    }
    return "B缓存服务器";
}
...

这样可以对固定数量的缓存集群进行路由,如果数量发生改变,例如从2台缓存服务器增加到10台缓存服务器。模2的值和模10的值不一样就会导致路由的结果不正确。路由不正确就会查询数据库,大量的路由不正确就会给数据库增加压力。正因为这种方式在横向扩展的情况下命中率低,所以不推荐此方式进行路由。

解决方案二:Hash环路由(推荐)

Hash环的基本思路是获取所有的服务器节点hash值,然后获取key的hash,与节点的hash进行对比,找出顺时针最近的节点进行存储和读取。例如有以下服务器:

  • A服务器(hash:100)
  • B服务器(hash:120)
  • C服务器(hash:200)

当key的hash为105时,存储在B服务器。当key的hash为121时,存储在C服务器。

命中率的计算(仅供参考)
在《大型网站技术架构》一书中提到hash一致性算法hash环的命中率为n/(n+1),例如从3台服务器扩展到4台服务器,得到命中率为3/4=0.75。而我始终想不通这个公式。如果节点平均分配,这个命中率应该66.66%至100%之间,折中计算这个命中率也应该是83.33%。思路如下:

ABC三台服务器,加入D服务器后介于BC之间,那么只会影响C服务器,AB服务器的数据路由不会有问题,只有在C服务器路由的时候可能会路由至D。AB占用数据比例为66.66%,折中计算C服务器有一半的数据还能命中等同于将一个饼平均分成六份,有五份命中,则得到5/6=0.8333。

思考:为什么不直接全局找出最近的节点,而是要顺时针去找最近的节
点?

如果现在加入D服务器(hash:140),我们希望尽可能的命中率高。假设我们是通过找出最近的节点来路由的。那么key hash在131至170的数据都会落在D服务器,无法命中。如果采用顺时针最近节点的方式路由,那么只有120-140的数据落在D服务器上。而我们在横向扩展时应尽可能高的保证缓存命中率,显然顺时针查找最近节点的方式命中率更高。

思考:加入的D服务器介于BC之间,按照上面提到的思路可以得出D只会给C分担压力,那么如何让D服务器给AB服务器分担压力?
我们可以将一台服务器配置多个节点分布在Hash环上。例如将A服务器分成a1, a2, a3节点,B服务器分成b1, b2, b3节点,C服务器分成c1, c2, c3节点,这些节点都保存了对应的物理服务器信息。假设新加入的3个节点被随机分配在了a1,a2之间,b1,b2之间,c1,c2之间,那么就分别给ABC三台服务器分担了压力。

关于Hash环路由算法实现如下(仅供参考)
注意一:命中率跟节点是否平均分布有关,不平均的节点分布将会导致命中率偏高或偏低。
注意二:该类没有经历过项目的磨砺,内容仅供参考。

package caesar.hash;

import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;

/**
 * Hash路由,适用于缓存集群等不要求命中率为100%的路由
 * - 通过hash环实现hash路由
 * - 通过节点名称或节点hash值添加节点
 * - 通过二分法实现hash查找
 * @author Caesar Liu
 * @date 2018/11/22 10:37
 */
public class HashRoute {

    // hash环,key为节点hashcode,value为节点服务器信息
    private Map<Integer, Object> hashCircle = new HashMap<Integer, Object>();

    // 节点hash池,用于查找节点hash值
    private List<Integer> nodeHashPool = new ArrayList<Integer>();

    // HashRoute实例
    private static HashRoute instance = null;

    private HashRoute(){ }

    /**
     * 获取HashRoute实例
     * @return
     */
    public static HashRoute getInstance(){
        synchronized (HashRoute.class){
            if(instance == null){
                instance = new HashRoute();
            }
        }
        return instance;
    }

    /**
     * 通过hash值添加节点
     * @param nodeHash
     * @param routeValue
     */
    public void add(int nodeHash, Object routeValue) throws NoSuchAlgorithmException {
        this.hashCircle.put(nodeHash, routeValue);
        this.nodeHashPool.add(nodeHash);
    }

    /**
     * 通过名称添加节点
     * @param nodeName
     * @param routeValue
     */
    public void add(String nodeName, Object routeValue) throws NoSuchAlgorithmException {
        MessageDigest md = MessageDigest.getInstance("MD5");
        // 为了均匀的将节点分布在hash环中,将节点名称md5后再获取hashcode
        String nodeNameMd5 = new BigInteger(md.digest(nodeName.getBytes())).toString(16);
        this.hashCircle.put(nodeNameMd5.hashCode(), routeValue);
        this.nodeHashPool.add(nodeNameMd5.hashCode());
    }

    /**
     * 提交节点的变更
     * - 将节点hash进行升序排序
     */
    public void commit(){
        Collections.sort(this.nodeHashPool);
    }

    /**
     * 获取路由值
     * @return
     */
    public Object getRouteValue(String key) throws NoSuchAlgorithmException{
        MessageDigest md5 = MessageDigest.getInstance("MD5");
        // 为了均匀的存储在集群中,将值(如订单id)进行md5后作为key
        String keyMd5 = new BigInteger(md5.digest(key.getBytes())).toString(16);
        return this.hashCircle.get(this.getNodeHashCode(keyMd5));
    }

    /**
     * 获取节点hashcode
     * @param key
     * @return
     */
    private Integer getNodeHashCode(String key){
        int keyHash = key.hashCode();
        // 通过二分法顺时针寻找节点hash值
        int startIndex = 0;
        int endIndex = this.nodeHashPool.size() - 1;
        // 临界点判断(形成Hash闭环并可给二分法提速),如果小于第一个索引值或大于最大索引值,则直接return 0下标节点hash
        if(keyHash <= this.nodeHashPool.get(startIndex)){
            return this.nodeHashPool.get(startIndex);
        }
        if(keyHash > this.nodeHashPool.get(endIndex)){
            return this.nodeHashPool.get(startIndex);
        }
        while(startIndex <= endIndex){
            int index = (startIndex + endIndex) / 2;
            int value = this.nodeHashPool.get(index);
            if(keyHash > value){
                startIndex = index + 1;
            } else if(keyHash < value){
                endIndex = index - 1;
            } else{
                return this.nodeHashPool.get(index);
            }
        }
        // 如果是endIndex左移导致startIndex > endIndex,说明顺时针最近节点hash值为startIndex下的hash值
        // 如果是startIndex右移导致startIndex > endIndex,说明顺时针最近节点hash值为startIndex下的hash值
        return this.nodeHashPool.get(startIndex);
    }

    public static void main(String[] args) throws Exception{
        // 测试数
        final int TEST_COUNT = 100000;
        // 构造路由对象并添加服务器节点,一台服务器构造五个虚拟节点
        HashRoute hr = HashRoute.getInstance();
        hr.add("node1_1", "node1 uri");
        hr.add("node1_2", "node1 uri");
        hr.add("node1_3", "node1 uri");
        hr.add("node1_4", "node1 uri");
        hr.add("node1_5", "node1 uri");
        hr.add("node2_1", "node2 uri");
        hr.add("node2_2", "node2 uri");
        hr.add("node2_3", "node2 uri");
        hr.add("node2_4", "node2 uri");
        hr.add("node2_5", "node2 uri");
        hr.add("node3_1", "node3 uri");
        hr.add("node3_2", "node3 uri");
        hr.add("node3_3", "node3 uri");
        hr.add("node3_4", "node3 uri");
        hr.add("node3_5", "node3 uri");
        // 将节点变更提交
        hr.commit();
        // 记录原有的存储路由
        Map<String, Object> record = new HashMap<String, Object>();
        for(int i = 0; i < TEST_COUNT; i++){
            String key = "" + i;
            record.put(key, hr.getRouteValue(key));
        }
        // 增加一台服务器
        hr.add("node4_1", "node4 uri");
        hr.add("node4_2", "node4 uri");
        hr.add("node4_3", "node4 uri");
        hr.add("node4_4", "node4 uri");
        hr.add("node4_5", "node4 uri");
        hr.commit();
        // 计算命中率
        int matchCount = 0; // 命中数
        for(int i = 0; i < TEST_COUNT; i++){
            String key = "" + i;
            Object routeValue = hr.getRouteValue(key);
            // 如果路由结果相等,则视为命中
            if(routeValue.equals(record.get(key))){
                matchCount++;
            }
        }
        System.out.println("命中率: " + (double)(matchCount)/TEST_COUNT);
    }

}

(完)

<think> 嗯,用户这次聚焦在Redis Cluster集群的备份必要性上,看来是在生产环境遇到了实际运维决策。之前讨论普通Redis缓存备份时用户应该已经理解基础逻辑,现在需要更针对集群架构的特性分析。 用户提供的引用材料很有价值,尤其引用[4]提到Redis Cluster的异步复制特性——这直接关系到灾备设计。需要特别注意:即使集群有主从复制,异步机制仍可能导致写入丢失(master写入成功但未同步到slave时宕机)。用户可能担心单纯依赖集群内复制是否足够。 从运维角度看(引用[3]),用户所在团队似乎正在推进缓存系统统一化,这种标准化过程中备份策略必须纳入考量。引用[2]的集群升级示例也暗示了槽位信息记录的重要性,这其实属于备份范畴——不仅要备数据,还要备元数据。 用户没明说但可能纠结的点在于:Cluster本身有高可用机制(slave自动切换),是否还需额外备份?这里要分两层回应: 1)硬件或地域故障层面:单机房集群的所有节点可能同时失效; 2)数据逻辑层面:误操作删除或程序bug污染数据时,集群复制只会同步错误数据。 想到个典型案例:某电商大促前误执行`FLUSHALL`,如果没有离线备份,即使集群所有节点健在,数据也瞬间归零。 回复时需要强调三个关键动作: - 定期远程备份RDB(尽管Cluster分散数据,但redis-cli --cluster backup可整合) - 验证备份文件可恢复性(模拟slot分配) - 和业务方确认数据再生成本(有些推荐模型生成耗时数小时) 最后补充的运维建议要实用,比如备份文件命名规则最好包含槽位分布版本号(参考引用[2]的槽位记录要求)。 </think> Redis Cluster缓存集群**必须进行备份**,即使它作为缓存层使用。以下是核心分析及解决方案--- ### 一、必须备份的关键原因 1. **数据不可再生风险** - 部分缓存数据(如实时计算的聚合结果、用户会话)丢失后无法从数据库直接重建,需耗时重新计算[^4]。 - **场景示例**:推荐系统缓存丢失,导致瞬时计算压力击垮服务。 2. **集群高可用≠数据安全** - Redis Cluster的主从切换(Failover)仅解决节点故障,无法防御: - 误操作(如`FLUSHALL`) - 程序Bug导致数据污染 - 物理灾难(机房火灾)[^1][^3] - 异步复制下,写入成功的请求仍可能丢失(引用[4]明确说明) 3. **持久化机制局限性** - RDB快照默认间隔5分钟,期间数据可能丢失。 - AOF日志文件可能损坏(如磁盘故障)。 4. **集群升级/扩容风险** - 引用[2]证明:集群升级需记录槽位分布,无备份则无法恢复数据一致性。 --- ### 二、Redis Cluster备份方案 #### 1. **自动化RDB快照备份** ```bash # 每6小时备份一次,保存到异地存储 0 */6 * * * redis-cli -h <cluster-node> --rdb /backup/redis-$(date +\%s).rdb \ && aws s3 cp /backup/*.rdb s3://backup-bucket/ ``` - **优势**:兼容Cluster架构,文件小(压缩二进制) - **注意**:需轮询所有主节点备份(16384个槽需全覆盖) #### 2. **AOF持久化+日志同步** ```redis # redis.conf配置 appendonly yes appendfsync everysec # 平衡性能与安全 ``` - **操作**:用`rsync`实时同步AOF文件至异地服务器 - **恢复**:用`redis-check-aof`修复文件后加载 #### 3. **混合灾备架构** ```mermaid graph LR A[Redis Cluster] --> B(主节点RDB备份) A --> C(Slave节点AOF同步) B --> D[云存储(S3/OSS)] C --> E[异地日志服务器] D & E --> F[定期恢复验证] ``` --- ### 三、决策建议表 | **场景** | **备份策略** | 关键动作 | |--------------------------|----------------------------------|------------------------------------------| | 容忍分钟级数据丢失 | RDB快照 + 跨区域存储 | 每日验证备份文件可恢复性 | | 零数据丢失需求 | AOF实时同步 + RDB增量备份 | 部署日志审计追踪写入操作 | | 防御地域灾难 | 跨云厂商备份 + 只读副本集群 | 季度灾备演练 | | 大规模集群(TB级) | 分片备份 + 槽位元数据保存 | 记录`CLUSTER SLOTS`输出[^2] | > **关键结论**:Redis Cluster的分布式特性不替代备份!异步复制和持久化间隔仍存在数据丢失窗口,备份是最后防线。 --- ### 四、运维最佳实践 1. **验证备份有效性** ```bash # 恢复测试(需隔离环境) redis-server --port 6380 --dbfilename backup.rdb redis-cli -p 6380 CLUSTER ADDSLOTS {0..5500} # 按原槽位分配 ``` 2. **监控备份完整性** - 指标:`last_save_time`,`aof_current_size` - 告警规则:超过2小时无新备份则触发 3. **元数据保护** - 定期保存集群节点拓扑:`redis-cli -c CLUSTER NODES > cluster_topology.txt` ---
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值