一致性 Hash 算法

假设现在有 5 台 redis 实例,每台实例负责保存一部分数据。客户端可以通过如下算法定位具体实例:

hash(key) % 5

随着业务发展,数据越来越多,5 台实例已经无法满足业务。现在要新增两台,新增两台后客户端算法如下:

hash(key) % 7

这就导致老算法计算出的值和新算法不一致:之前 hash(a) % 5 = 1,key 为 a 的键值对保存在 1 号实例,而 hash(a) % 7 的值可能不为 1。也就是说:因为扩容(缩容),大多数旧 key 都失效了。为了解决该问题,一致性 Hash 算法诞生了

一致性 Hash 算法不再根据实例数取模,而是以 2 ^ 32 - 1(int 最大整数值)取模,取模后值域为 0 ~ 2 ^ 32 - 1,之后根据不同 redis 实例的 IP 或 其它唯一属性进行 Hash 算法:

假设 redis 实例 x、y、z 经过 hash 算法后得出的值分别为 a,b,c(a < b < c 并且a、b、c 都小于 2 ^ 32 - 1),那么之后 x、y,z 负责的范围分别是:

  • x 实例:c ~ a,也就是 c ~ 2 ^ 32 -1 和 0 ~ a 的并集
  • y 实例:a ~ b
  • z 实例:b ~ c

对于随机 key,根据一致性 hash 算法计算出的值和上述范围一起判断应该放到哪个实例

此时如果 x,y,z 实例中的 x 挂掉了,hash 结果处于 c ~ a 范围内的 key 会打到实例 y,其它区间无影响。如果要在 x ~ z 之间新增实例 k,也只会影响 hash 值处于 c ~ d 区间的 key,原来它们会打到实例 x,现在会打到 实例 k,其它区间无影响

也就是说,使用一致性 Hash 算法能大大减少扩容(缩容)所带来的影响,只需要重定向一小部分数据,有更好的容错性和可扩展性

假设现在只存在两个实例 x,y,他们一致性 hash 算法得出的值分别为 a,b。如果 a 和 b 的值特别接近,那么打到实例 y 上的数据会远远小于实例 x 上的数据,为了解决该问题,我们可以对每个实例计算多个虚拟节点,之后根据虚拟节点来完成划分,只需要额外增加一层从虚拟节点到实例的映射关系即可


最后简单看一个一致性 Hash 算法实现:

// 一致性 hash 算法接口
public interface ConsistencyHashService {
    long hash(String key);
}

// 一致性 hash 算法接口实现
// MurMurHash算法,性能更高,结果更离散
// 摘自简书,原文 [点击这里](https://www.jianshu.com/p/528ce5cd7e8f)
public class ConsistencyHashServiceImpl implements ConsistencyHashService {

    @Override
    public long hash(String key) {
        ByteBuffer buf = ByteBuffer.wrap(key.getBytes());
        int seed = 0x1234ABCD;
        ByteOrder byteOrder = buf.order();
        buf.order(ByteOrder.LITTLE_ENDIAN);
        long m = 0xc6a4a7935bd1e995L;
        int r = 47;
        long h = seed ^ (buf.remaining() * m);
        long k;
        while (buf.remaining() >= 8) {
            k = buf.getLong();
            k *= m;
            k ^= k >>> r;
            k *= m;
            h ^= k;
            h *= m;
        }
        if (buf.remaining() > 0) {
            ByteBuffer finish = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN);
            finish.put(buf).rewind();
            h ^= finish.getLong();
            h *= m;
        }
        h ^= h >>> r;
        h *= m;
        h ^= h >>> r;
        buf.order(byteOrder);
        return h;
    }
}

// 模拟 redis 节点
public class RedisNode {

    public String ip;
    public String name;

    public RedisNode(String ip, String name) {
        this.ip = ip;
        this.name = name;
    }

    public String getIp() {
        return ip;
    }

    public void setIp(String ip) {
        this.ip = ip;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    
    @Override
    // 使用 ip 计算 redis 虚拟节点 hash 值
    public String toString() {
        return ip;
    }

}

// 一致性 hash 操作
public class ConsistencyHash<T> {

    /**
     * 使用的一致性 hash 算法
     */
    private final ConsistencyHashService service;
    /**
     * 每个 redis 节点虚拟节点的数量
     */
    private final int num;
    /**
     * key 为 hash 值,value 为具体 redis 节点
     * hash 值实际就对应不同虚拟节点
     */
    private final SortedMap<Long, T> map = new TreeMap();

    public ConsistencyHash(ConsistencyHashService service, int num, Collection<T> list) {
        this.service = service;
        this.num = num;
        for (T t : list) {
            add(t);
        }
    }

    /**
     * 增加虚拟节点
     *
     * @param t
     */
    public void add(T t) {
        for (int i = 0; i < num; ++i) {
            map.put(service.hash(t.toString() + i), t);
        }
    }

    /**
     * 删除虚拟节点
     *
     * @param t
     */
    public void delete(T t) {
        for (int i = 0; i < num; ++i) {
            map.remove(service.hash(t.toString() + i));
        }
    }

    /**
     * 获取当前 key 应该放到哪个 redis 实例
     *
     * @param key
     * @return
     */
    public T get(String key) {
        if (map.isEmpty()) {
            return null;
        }
        long hash = service.hash(key);
        // 包含说明当前 key 正好处于某个虚拟节点,否则根据范围判断
        if (!map.containsKey(hash)) {
            // 返回 key 大于 hash 的 所有 key value 组成的 map
            SortedMap<Long, T> temp = map.tailMap(hash);
            // map 为空说明没有大于 hash 的虚拟节点,存到从 0 开始第一个虚拟节点
            // map 不为空说明 hash 后面存在虚拟节点,存到对应虚拟节点
            hash = temp.isEmpty() ? map.firstKey() : temp.firstKey();
        }
        // 根据虚拟节点定位到具体 redis 实例
        return map.get(hash);
    }

}

具体测试类源码如下:

public class ConsistencyTest {

    private static final String PRE_IP = "192.168.0.";

    public static void main(String[] args) {
        // 用来记录每个实例保存多少节点,方便验证
        Map<String, Integer> map = new HashMap<>();
        List<RedisNode> list = new ArrayList<RedisNode>();
        // 模拟 10 个 redis 实例
        for (int i = 1; i <= 10; ++i) {
            String realIP = PRE_IP + i;
            map.put(realIP, 0);
            list.add(new RedisNode(realIP, "主机" + i));
        }
        ConsistencyHashService service = new ConsistencyHashServiceImpl();
        // 模拟每个 redis 实例包含 500 个虚拟节点
        ConsistencyHash cHash = new ConsistencyHash(service, 500, list);
        // 模拟保存 50000 个节点
        for (int i = 0; i < 50000; ++i) {
            String key = UUID.randomUUID().toString() + i;
            RedisNode node = (RedisNode) cHash.get(key);
            map.put(node.getIp(), map.get(node.getIp()) + 1);
        }
        // 打印每台实例保存了多少元素
        for (int i = 1; i <= 10; ++i) {
            RedisNode node = list.get(i - 1);
            System.out.println(node.getIp() + "保存节点数:" + map.get(node.getIp()));
        }
        // 尝试删除一个实例
        cHash.delete(list.get(0));
        // 清空记录
        for (int i = 1; i <= 10; ++i) {
            RedisNode node = list.get(i - 1);
            map.put(node.getIp(), 0);
        }
        // 模拟保存 50000 个节点
        for (int i = 0; i < 50000; ++i) {
            String key = UUID.randomUUID().toString() + i;
            RedisNode node = (RedisNode) cHash.get(key);
            map.put(node.getIp(), map.get(node.getIp()) + 1);
        }
        for (int i = 1; i <= 10; ++i) {
            RedisNode node = list.get(i - 1);
            System.out.println(node.getIp() + "保存节点数:" + map.get(node.getIp()));
        }
    }
    
}

测试结果如下:

192.168.0.1保存节点数:5181
192.168.0.2保存节点数:5160
192.168.0.3保存节点数:4805
192.168.0.4保存节点数:4764
192.168.0.5保存节点数:5170
192.168.0.6保存节点数:4271
192.168.0.7保存节点数:5239
192.168.0.8保存节点数:5177
192.168.0.9保存节点数:5157
192.168.0.10保存节点数:5076
192.168.0.1保存节点数:0
192.168.0.2保存节点数:5718
192.168.0.3保存节点数:5169
192.168.0.4保存节点数:5553
192.168.0.5保存节点数:5455
192.168.0.6保存节点数:4943
192.168.0.7保存节点数:5570
192.168.0.8保存节点数:5733
192.168.0.9保存节点数:6039
192.168.0.10保存节点数:5820

从结果可以看出,所有节点相对较均匀的保存到不同实例

最后简单提一下:虚拟节点只能解决数据倾斜问题,如果 redis 实例本身数量不够多,就仍有可能由于数据量过大而宕机,此时只能扩容。一般情况下,一致性 hash 算法越高效、结果越离散(均匀),Redis 实例数越多,整体效率越高

Redis Cluster 实际并没有使用上面我提到的分流方法,关于这点以后在 Redis Cluster 集群篇再介绍

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值