MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis 字典重点特性的实际应用案例

2024-01-203.6k 阅读

Redis 字典概述

Redis 字典是 Redis 中一种非常重要的数据结构,它实现了关联数组(也称作哈希表)的功能。在 Redis 内部,字典被广泛用于实现数据库,即 Redis 使用字典来保存所有的键值对。同时,像哈希类型的数据结构底层也是基于字典实现的。

字典结构剖析

Redis 的字典使用哈希表作为底层实现。哈希表由 dict.h/dictht 结构定义:

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;
  • table 是一个数组,数组中的每个元素都是一个指向 dictEntry 结构的指针,dictEntry 结构用于保存键值对。
  • size 记录了哈希表的大小,即 table 数组的长度。
  • sizemasksize - 1,它在计算哈希值索引时用于对哈希值进行掩码操作,以确定键值对在 table 中的位置。
  • used 记录了哈希表中已使用的节点数量。

dictEntry 结构定义如下:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;
  • key 是键。
  • v 是值,它是一个联合体,可以保存不同类型的值。
  • next 是指向另一个 dictEntry 结构的指针,用于解决哈希冲突,通过链地址法实现。

字典的操作

  1. 添加键值对:计算键的哈希值,然后使用 sizemask 对哈希值进行掩码操作得到索引位置。如果该位置已有节点,则通过 next 指针将新节点链在该位置的链表末尾。
  2. 查找键值对:同样计算键的哈希值并得到索引位置,然后沿着链表查找匹配的键。
  3. 删除键值对:先查找要删除的键值对,找到后调整链表指针,将其从链表中移除。

Redis 字典重点特性

哈希算法

Redis 使用 MurmurHash2 算法来计算键的哈希值。MurmurHash2 算法具有计算速度快、分布均匀的特点,能有效减少哈希冲突。例如,在对大量不同的键进行哈希计算时,MurmurHash2 可以将这些键较为均匀地分布在哈希表中。

哈希冲突解决

Redis 采用链地址法解决哈希冲突。当多个键计算得到的哈希值相同(哈希冲突)时,这些键值对会被存储在同一个哈希表槽位对应的链表中。虽然链地址法可以解决哈希冲突,但链表过长会影响查找效率。因此,Redis 会在适当的时候进行 rehash 操作。

Rehash 机制

  1. 触发条件
    • 当哈希表中已使用的节点数(used)等于哈希表大小(size)时,即负载因子(load factor = used / size)达到 1,会触发 rehash。
    • 当 Redis 执行 BGSAVEBGREWRITEAOF 命令时,如果负载因子大于 5,也会触发 rehash。
  2. Rehash 过程
    • 为字典的 ht[1] 分配空间,ht[1] 的大小一般是 ht[0] 当前大小的两倍(如果 ht[0].used 小于 ht[0].size,则 ht[1] 的大小为 ht[0].size)。
    • ht[0] 中的所有键值对重新计算哈希值和索引位置,然后移动到 ht[1] 中。
    • ht[0] 中的所有键值对都迁移到 ht[1] 后,释放 ht[0],将 ht[1] 设置为 ht[0],并重新为 ht[1] 分配一个空白哈希表,等待下一次 rehash。

渐进式 Rehash

由于直接进行 rehash 可能会对服务器性能产生较大影响,Redis 采用渐进式 rehash。在渐进式 rehash 过程中,字典会同时保留 ht[0]ht[1] 两个哈希表,在每次对字典进行增删改查操作时,会顺带将 ht[0] 中的少量键值对迁移到 ht[1] 中。同时,在处理客户端请求的间隙,也会进行部分键值对的迁移,直到 ht[0] 中的所有键值对都迁移到 ht[1] 为止。

实际应用案例

缓存系统

  1. 场景描述:在一个高并发的 Web 应用中,经常需要从数据库中读取一些不经常变化的数据,如网站配置信息、商品分类信息等。为了减少数据库的压力,提高响应速度,可以使用 Redis 作为缓存。
  2. 实现思路:将从数据库中读取的数据以键值对的形式存储在 Redis 字典中。当应用需要这些数据时,先从 Redis 中查找,如果存在则直接返回,不存在再从数据库读取并存储到 Redis 中。
  3. 代码示例(Python + Redis - Py)
import redis

# 连接 Redis 服务器
r = redis.Redis(host='localhost', port=6379, db = 0)

def get_config_from_db():
    # 模拟从数据库获取配置信息
    return {'site_name': 'My Website', 'default_language': 'en'}

def get_config():
    config = r.hgetall('config')
    if not config:
        config = get_config_from_db()
        r.hmset('config', config)
    return config

print(get_config())

在上述代码中,hgetall 用于从 Redis 哈希(基于字典实现)中获取所有键值对,hmset 用于设置多个键值对。

实时分析系统

  1. 场景描述:在一个实时分析系统中,需要统计网站的实时访问量、不同页面的访问次数等信息。由于数据量较大且需要实时更新,使用 Redis 字典可以高效地实现这些统计功能。
  2. 实现思路:以页面 URL 作为键,访问次数作为值存储在 Redis 字典中。每次有页面访问时,使用 Redis 的原子操作对相应键的值进行递增。
  3. 代码示例(Java + Jedis)
import redis.clients.jedis.Jedis;

public class PageVisitCounter {
    private Jedis jedis;

    public PageVisitCounter() {
        jedis = new Jedis("localhost", 6379);
    }

    public void incrementPageVisit(String pageUrl) {
        jedis.hincrBy("page_visits", pageUrl, 1);
    }

    public long getPageVisitCount(String pageUrl) {
        return Long.parseLong(jedis.hget("page_visits", pageUrl));
    }

    public static void main(String[] args) {
        PageVisitCounter counter = new PageVisitCounter();
        counter.incrementPageVisit("/home");
        System.out.println("Home page visit count: " + counter.getPageVisitCount("/home"));
    }
}

这里 hincrBy 用于对哈希表中指定键的值进行原子递增,hget 用于获取指定键的值。

分布式锁

  1. 场景描述:在分布式系统中,多个节点可能同时尝试执行一些需要互斥的操作,如资源的分配、数据的修改等。使用 Redis 字典可以实现分布式锁,确保同一时间只有一个节点能执行这些操作。
  2. 实现思路:利用 Redis 字典的单线程原子性操作,在 Redis 中设置一个特定的键值对来表示锁。当一个节点尝试获取锁时,通过 SETNX(SET if Not eXists)命令尝试设置这个键值对,如果设置成功则表示获取到锁,否则表示锁已被其他节点持有。释放锁时,删除这个键值对。
  3. 代码示例(Go + Redigo)
package main

import (
    "fmt"
    "github.com/gomodule/redigo/redis"
    "time"
)

func acquireLock(conn redis.Conn, lockKey string, lockValue string, expiration time.Duration) bool {
    ret, err := redis.Bool(conn.Do("SETNX", lockKey, lockValue))
    if err != nil {
        fmt.Println("Error acquiring lock:", err)
        return false
    }
    if ret {
        _, err = conn.Do("EXPIRE", lockKey, int(expiration.Seconds()))
        if err != nil {
            fmt.Println("Error setting lock expiration:", err)
            // 这里可以选择释放锁或者进行其他处理
        }
    }
    return ret
}

func releaseLock(conn redis.Conn, lockKey string) {
    _, err := conn.Do("DEL", lockKey)
    if err != nil {
        fmt.Println("Error releasing lock:", err)
    }
}

func main() {
    conn, err := redis.Dial("tcp", "localhost:6379")
    if err != nil {
        fmt.Println("Error connecting to Redis:", err)
        return
    }
    defer conn.Close()

    lockKey := "my_distributed_lock"
    lockValue := "unique_value"
    expiration := 5 * time.Second

    if acquireLock(conn, lockKey, lockValue, expiration) {
        fmt.Println("Lock acquired. Performing critical section...")
        // 执行临界区代码
        time.Sleep(3 * time.Second)
        releaseLock(conn, lockKey)
        fmt.Println("Lock released.")
    } else {
        fmt.Println("Failed to acquire lock.")
    }
}

在这段 Go 代码中,acquireLock 函数尝试获取锁,releaseLock 函数用于释放锁。通过 SETNXEXPIRE 命令来实现锁的获取和设置过期时间,利用 DEL 命令释放锁。

购物车系统

  1. 场景描述:在电商应用的购物车模块中,需要为每个用户存储其购物车中的商品信息,包括商品 ID、数量等。由于用户数量众多且购物车操作频繁,使用 Redis 字典可以高效地管理这些数据。
  2. 实现思路:以用户 ID 作为外层键,每个用户的购物车数据以哈希(基于字典)形式存储,其中商品 ID 作为内层键,商品数量作为值。例如,添加商品时,在对应的用户购物车哈希中增加商品 ID 及数量;更新商品数量时,修改对应商品 ID 的值;删除商品时,从哈希中删除对应的键值对。
  3. 代码示例(Node.js + ioredis)
const Redis = require('ioredis');
const redis = new Redis(6379, 'localhost');

async function addToCart(userId, productId, quantity) {
    await redis.hincrby(`cart:${userId}`, productId, quantity);
}

async function updateCartQuantity(userId, productId, newQuantity) {
    await redis.hset(`cart:${userId}`, productId, newQuantity);
}

async function removeFromCart(userId, productId) {
    await redis.hdel(`cart:${userId}`, productId);
}

async function getCart(userId) {
    return await redis.hgetall(`cart:${userId}`);
}

// 示例使用
addToCart('user1', 'product1', 2).then(() => {
    return getCart('user1');
}).then(cart => {
    console.log('Cart:', cart);
});

在上述 Node.js 代码中,hincrby 用于增加购物车中商品的数量,hset 用于更新商品数量,hdel 用于删除商品,hgetall 用于获取整个购物车的商品信息。

排行榜系统

  1. 场景描述:在游戏应用中,经常需要实现排行榜功能,如玩家的积分排行榜、等级排行榜等。使用 Redis 字典可以高效地实现这些排行榜的实时更新和查询。
  2. 实现思路:以排行榜名称作为键,玩家 ID 与对应积分(或等级等排名依据)作为哈希表的键值对。通过 Redis 的 ZADD 命令(有序集合,底层也依赖字典相关操作)可以将玩家信息添加到排行榜中,并根据积分自动排序。查询排行榜时,可以使用 ZRANGEZREVRANGE 命令获取排名靠前或靠后的玩家列表。
  3. 代码示例(C# + StackExchange.Redis)
using StackExchange.Redis;
using System;

class LeaderboardManager {
    private ConnectionMultiplexer redis;
    private IDatabase db;

    public LeaderboardManager() {
        redis = ConnectionMultiplexer.Connect("localhost:6379");
        db = redis.GetDatabase();
    }

    public void AddPlayerToLeaderboard(string leaderboardName, string playerId, double score) {
        db.SortedSetAdd(leaderboardName, playerId, score);
    }

    public RedisValue[] GetTopPlayers(string leaderboardName, int count) {
        return db.SortedSetRangeByRank(leaderboardName, 0, count - 1, order: Order.Descending);
    }

    public static void Main() {
        LeaderboardManager manager = new LeaderboardManager();
        manager.AddPlayerToLeaderboard("score_leaderboard", "player1", 100);
        manager.AddPlayerToLeaderboard("score_leaderboard", "player2", 150);
        var topPlayers = manager.GetTopPlayers("score_leaderboard", 2);
        foreach (var player in topPlayers) {
            Console.WriteLine(player);
        }
    }
}

在这段 C# 代码中,SortedSetAdd 方法用于将玩家添加到排行榜,SortedSetRangeByRank 方法用于获取排名靠前的玩家列表。

应用中的优化与注意事项

优化

  1. 合理设置哈希表初始大小:根据预估的数据量合理设置 Redis 字典的初始大小,可以减少 rehash 的次数。例如,如果预计存储 1000 个键值对,可以将初始大小设置为略大于 1000 的 2 的幂次方,如 2048。
  2. 选择合适的键:尽量选择分布均匀的键,避免大量键集中在少数哈希槽位,导致链表过长影响性能。例如,在使用哈希表存储用户信息时,可以使用用户 ID 作为键,因为用户 ID 通常具有较好的随机性。
  3. 批量操作:尽量使用批量操作命令,如 HMSETHGETALL 等,减少与 Redis 的交互次数。在缓存系统中,一次获取多个配置项时使用 HGETALL 比多次使用 HGET 效率更高。

注意事项

  1. 哈希冲突处理:虽然 Redis 的哈希算法和链地址法能有效处理哈希冲突,但在极端情况下,如键的分布非常不均匀,链表过长仍会影响性能。因此,需要关注哈希表的负载因子,必要时手动调整哈希表大小。
  2. 渐进式 Rehash 期间的数据一致性:在渐进式 rehash 过程中,字典会同时存在 ht[0]ht[1],读写操作可能涉及到两个哈希表。因此,在进行复杂操作时,需要确保数据一致性。例如,在读取数据时,要先从 ht[1] 查找,再从 ht[0] 查找。
  3. 内存管理:Redis 字典存储数据需要占用内存,要注意监控内存使用情况,避免内存溢出。可以通过 Redis 的内存相关配置,如 maxmemory 来限制内存使用,并结合 maxmemory - policy 策略在内存不足时进行数据淘汰。

通过深入理解 Redis 字典的重点特性,并将其应用到实际场景中,结合优化和注意事项,可以充分发挥 Redis 在不同系统中的高效数据管理能力,提升系统的性能和稳定性。无论是缓存、实时分析、分布式锁还是购物车、排行榜等应用场景,Redis 字典都能提供强大的支持。在实际应用中,需要根据具体业务需求和系统特点,灵活运用这些知识,以实现最优的解决方案。