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

Redis在缓存设计中的应用与优化

2023-06-211.2k 阅读

Redis基础概述

Redis简介

Redis 是一个开源的、基于内存的数据结构存储系统,它可以用作数据库、缓存和消息中间件。Redis 支持多种数据结构,如字符串(String)、哈希(Hash)、列表(List)、集合(Set)和有序集合(Sorted Set)等。这些丰富的数据结构使得 Redis 在不同场景下都能展现出强大的功能,尤其是在缓存设计中,能够满足各种复杂的缓存需求。

Redis数据结构特点与缓存应用优势

  1. 字符串(String)
    • 特点:最基本的数据结构,一个 key 对应一个 value。value 不仅可以是字符串,还可以是数字等类型。
    • 缓存应用优势:常用于缓存简单的对象,比如用户的基本信息(用户名、头像地址等)。在 Web 应用中,对于一些不经常变化且访问频繁的数据,使用字符串结构缓存可以快速响应请求,减少数据库的负载。例如,缓存一篇博客文章的标题和简短摘要。
    • 代码示例(以 Python 为例)
import redis

r = redis.Redis(host='localhost', port=6379, db = 0)
r.set('blog:1:title', 'Redis 缓存设计实战')
title = r.get('blog:1:title')
print(title.decode('utf - 8'))
  1. 哈希(Hash)
    • 特点:哈希表结构,一个 key 对应多个 field - value 对。适用于存储对象的多个属性。
    • 缓存应用优势:在缓存复杂对象时非常有用,比如缓存用户完整信息(包括姓名、年龄、性别、联系方式等多个属性)。相比于字符串结构,哈希结构在存储和读取对象的部分属性时更高效,并且不会因为修改一个属性而导致整个对象缓存的更新。
    • 代码示例(以 Python 为例)
user_info = {
    'name': 'John',
    'age': 30,
    'gender': 'Male'
}
r.hmset('user:1', user_info)
name = r.hget('user:1', 'name')
print(name.decode('utf - 8'))
  1. 列表(List)
    • 特点:简单的字符串列表,按照插入顺序排序。支持在列表的两端进行插入和删除操作。
    • 缓存应用优势:可以用于缓存一些有序的数据,比如文章的评论列表。通过列表的两端操作,可以方便地实现最新评论的插入和获取。还可以用于实现简单的消息队列,将消息插入到列表一端,另一端进行消费。
    • 代码示例(以 Python 为例)
comments = ['Great article!', 'Interesting topic']
for comment in comments:
    r.rpush('blog:1:comments', comment)
first_comment = r.lpop('blog:1:comments')
print(first_comment.decode('utf - 8'))
  1. 集合(Set)
    • 特点:无序的字符串集合,集合中的每个元素都是唯一的。支持集合的交、并、差等操作。
    • 缓存应用优势:在缓存一些需要去重的数据时非常合适,比如网站的访客 IP 集合。可以方便地统计独立访客数量,并且通过集合操作可以实现一些复杂的功能,如找出同时访问了多个页面的用户。
    • 代码示例(以 Python 为例)
ips = ['192.168.1.1', '10.0.0.1', '192.168.1.1']
for ip in ips:
    r.sadd('website:visitors', ip)
count = r.scard('website:visitors')
print(count)
  1. 有序集合(Sorted Set)
    • 特点:和集合类似,但每个元素都会关联一个分数(score),通过分数来对元素进行排序。
    • 缓存应用优势:常用于排行榜类的场景,比如游戏玩家的积分排行榜。根据分数的变化,可以实时更新排行榜,并且高效地获取排名靠前或特定范围内的玩家信息。
    • 代码示例(以 Python 为例)
players = {
    'Player1': 1000,
    'Player2': 800,
    'Player3': 1200
}
for player, score in players.items():
    r.zadd('game:leaderboard', {player: score})
top_players = r.zrevrange('game:leaderboard', 0, 2, withscores=True)
for player, score in top_players:
    print(player.decode('utf - 8'), score)

Redis在缓存设计中的应用场景

页面缓存

在 Web 应用中,页面缓存是 Redis 常见的应用场景之一。对于一些不经常变化的页面,比如公司的介绍页面、产品的静态展示页面等,可以将整个页面缓存到 Redis 中。当用户请求这些页面时,首先检查 Redis 中是否存在对应的缓存页面,如果存在则直接返回,避免了重复的页面渲染和数据库查询操作。

  1. 实现原理:在服务器端,当页面第一次被请求时,生成页面内容并将其存储到 Redis 中,设置一个合适的过期时间。后续请求该页面时,先从 Redis 中获取缓存内容,如果缓存存在且未过期,则直接返回给用户;否则重新生成页面并更新缓存。
  2. 代码示例(以 Java Spring Boot 为例)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class PageCacheService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public String getCachedPage(String pageKey) {
        return redisTemplate.opsForValue().get(pageKey);
    }

    public void cachePage(String pageKey, String pageContent, long expirationTime) {
        redisTemplate.opsForValue().set(pageKey, pageContent, expirationTime);
    }
}

在控制器中使用:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PageController {
    @Autowired
    private PageCacheService pageCacheService;

    @GetMapping("/page/{pageKey}")
    public String getPage(@PathVariable String pageKey) {
        String cachedPage = pageCacheService.getCachedPage(pageKey);
        if (cachedPage!= null) {
            return cachedPage;
        }
        // 如果缓存不存在,生成页面内容
        String pageContent = generatePageContent(pageKey);
        pageCacheService.cachePage(pageKey, pageContent, 3600); // 缓存1小时
        return pageContent;
    }

    private String generatePageContent(String pageKey) {
        // 实际生成页面内容的逻辑
        return "This is the content of page " + pageKey;
    }
}

数据缓存

  1. 单对象缓存
    • 应用场景:在数据库中有很多单条记录的数据,比如用户信息、商品信息等,这些数据在系统中经常被读取,且更新频率相对较低。将这些单条记录缓存到 Redis 中,可以显著提高读取性能。
    • 实现方式:以对象的唯一标识(如用户 ID、商品 ID)作为 Redis 的 key,对象的序列化数据作为 value 进行存储。当需要获取对象时,先从 Redis 中查询,如果存在则直接返回;如果不存在则从数据库中读取,然后将读取到的对象存入 Redis 缓存。
    • 代码示例(以 Node.js 为例)
const redis = require('redis');
const { promisify } = require('util');

const client = redis.createClient();

async function getSingleObject(id) {
    let obj = await promisify(client.get).bind(client)(`object:${id}`);
    if (obj) {
        return JSON.parse(obj);
    }
    // 从数据库中获取对象
    const dbObj = await getObjectFromDatabase(id);
    if (dbObj) {
        await promisify(client.set).bind(client)(`object:${id}`, JSON.stringify(dbObj));
        return dbObj;
    }
    return null;
}

async function getObjectFromDatabase(id) {
    // 实际从数据库获取对象的逻辑
    return { id, name: 'Sample Object' };
}
  1. 列表数据缓存
    • 应用场景:对于一些列表数据,如文章列表、订单列表等,缓存这些列表可以减少数据库的分页查询压力。尤其是在列表数据变化不频繁的情况下,缓存的效果更为显著。
    • 实现方式:可以将整个列表数据序列化后存储到 Redis 中,也可以根据分页情况,将不同页的列表数据分别缓存。当请求列表数据时,先从 Redis 中获取,如果缓存存在则直接返回;否则从数据库中查询,更新缓存后再返回。
    • 代码示例(以 C# 为例)
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.Text.Json;

class ListCacheService
{
    private readonly ConnectionMultiplexer _redis;
    private readonly IDatabase _db;

    public ListCacheService()
    {
        _redis = ConnectionMultiplexer.Connect("localhost:6379");
        _db = _redis.GetDatabase();
    }

    public async Task<List<T>> GetList<T>(string key)
    {
        var cachedValue = await _db.StringGetAsync(key);
        if (!cachedValue.IsNullOrEmpty)
        {
            return JsonSerializer.Deserialize<List<T>>(cachedValue);
        }
        // 从数据库获取列表数据
        var list = await GetListFromDatabase<T>();
        if (list!= null)
        {
            await _db.StringSetAsync(key, JsonSerializer.Serialize(list));
            return list;
        }
        return null;
    }

    private async Task<List<T>> GetListFromDatabase<T>()
    {
        // 实际从数据库获取列表数据的逻辑
        return new List<T>();
    }
}

缓存穿透、缓存雪崩与缓存击穿问题及解决

  1. 缓存穿透
    • 问题描述:缓存穿透是指查询一个一定不存在的数据,由于缓存中没有,每次都会去数据库查询,导致数据库压力增大。例如,恶意用户不断请求一个不存在的商品 ID。
    • 解决方法
      • 布隆过滤器(Bloom Filter):在查询数据前,先通过布隆过滤器判断数据是否存在。布隆过滤器是一种概率型数据结构,它可以快速判断一个元素是否在集合中。如果布隆过滤器判断数据不存在,那么可以直接返回,不需要查询数据库。虽然布隆过滤器存在一定的误判率,但可以通过调整参数来控制误判率在可接受范围内。
      • 空值缓存:当查询数据库发现数据不存在时,也将空值缓存到 Redis 中,并设置一个较短的过期时间,这样后续相同的查询可以直接从缓存中获取空值,避免多次查询数据库。
    • 代码示例(以 Go 语言使用布隆过滤器为例)
package main

import (
    "github.com/willf/bloom"
    "fmt"
    "github.com/go - redis/redis/v8"
    "context"
)

var ctx = context.Background()

func main() {
    rdb := redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })

    // 初始化布隆过滤器
    bf := bloom.New(1000, 0.01)
    // 假设已经将所有存在的商品 ID 添加到布隆过滤器中
    bf.Add([]byte("1"))
    bf.Add([]byte("2"))

    productID := "3"
    if!bf.Test([]byte(productID)) {
        fmt.Println("Product does not exist, no need to query database")
        return
    }
    // 如果布隆过滤器判断可能存在,再查询 Redis 和数据库
    result, err := rdb.Get(ctx, productID).Result()
    if err == nil {
        fmt.Println("Product from cache:", result)
    } else {
        // 查询数据库
        product := getProductFromDB(productID)
        if product!= nil {
            rdb.Set(ctx, productID, product, 0)
            fmt.Println("Product from database and cached:", product)
        } else {
            // 空值缓存
            rdb.Set(ctx, productID, "", 60)
            fmt.Println("Product does not exist, cached null value")
        }
    }
}

func getProductFromDB(id string) string {
    // 实际从数据库获取商品的逻辑
    return ""
}
  1. 缓存雪崩
    • 问题描述:缓存雪崩是指在同一时间大量的缓存失效,导致大量请求直接落到数据库上,造成数据库压力过大甚至崩溃。通常是因为设置了相同的过期时间,例如一批缓存都设置了 1 小时过期,1 小时后这些缓存同时失效。
    • 解决方法
      • 随机过期时间:为每个缓存设置一个随机的过期时间,避免大量缓存同时过期。例如,将过期时间设置为 1 小时到 1 小时 30 分钟之间的随机值。
      • 加锁排队:当缓存失效时,先通过加锁机制,只允许一个请求去查询数据库并更新缓存,其他请求等待。这样可以避免大量请求同时查询数据库。
    • 代码示例(以 Python 实现随机过期时间为例)
import redis
import random

r = redis.Redis(host='localhost', port=6379, db = 0)

def setWithRandomExpiry(key, value):
    min_expiry = 3600
    max_expiry = 5400
    expiry = random.randint(min_expiry, max_expiry)
    r.setex(key, expiry, value)
  1. 缓存击穿
    • 问题描述:缓存击穿是指一个热点 key,在缓存过期的瞬间,大量请求同时访问,导致这些请求都直接落到数据库上。例如,一个热门商品的缓存过期时,大量用户同时请求该商品信息。
    • 解决方法
      • 互斥锁:在缓存失效时,使用互斥锁(如 Redis 的 SETNX 命令)来保证只有一个请求可以去查询数据库并更新缓存,其他请求等待。当获取到锁的请求更新完缓存后,释放锁,其他请求可以从缓存中获取数据。
      • 永不过期:对于热点数据,可以设置缓存永不过期,同时在更新数据库时,主动更新缓存,这样可以避免缓存过期瞬间的高并发问题。
    • 代码示例(以 Java 使用互斥锁解决缓存击穿为例)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class CacheBreakdownService {
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public String getValue(String key) {
        String value = redisTemplate.opsForValue().get(key);
        if (value == null) {
            String lockKey = "lock:" + key;
            boolean locked = false;
            try {
                locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 1, TimeUnit.MINUTES);
                if (locked) {
                    // 从数据库获取值
                    value = getValueFromDatabase(key);
                    if (value!= null) {
                        redisTemplate.opsForValue().set(key, value);
                    }
                } else {
                    // 等待一段时间后重试
                    Thread.sleep(100);
                    return getValue(key);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                if (locked) {
                    redisTemplate.delete(lockKey);
                }
            }
        }
        return value;
    }

    private String getValueFromDatabase(String key) {
        // 实际从数据库获取值的逻辑
        return "Sample value from database";
    }
}

Redis缓存优化策略

缓存数据结构选择优化

  1. 根据访问模式选择
    • 读多写少:如果数据主要是读操作,且对读取性能要求极高,对于简单数据可以优先选择字符串结构。例如,对于一些配置信息,如网站的版权声明、客服电话等,使用字符串结构缓存可以快速读取。对于复杂对象,哈希结构是较好的选择,因为它可以高效地读取对象的部分属性。
    • 读写均衡:当读写操作频率较为均衡时,需要综合考虑数据结构的特点。对于有序数据,如按时间排序的日志记录,有序集合可能更合适,既能方便地插入新记录,又能高效地按时间范围查询。对于无序且需要去重的数据,集合结构是不错的选择。
    • 写多读少:在写多读少的场景下,要考虑数据结构的写入性能。列表结构在两端插入操作上性能较好,适合用于消息队列等场景,快速将消息写入队列。而哈希结构在更新对象部分属性时也比较高效,不需要更新整个对象。
  2. 根据数据规模选择
    • 小数据量:对于小数据量的缓存,各种数据结构的性能差异并不明显,此时可以根据数据的逻辑结构来选择。例如,如果是单个简单值,字符串结构即可;如果是多个相关属性的对象,哈希结构更合适。
    • 大数据量:当缓存数据量较大时,需要考虑数据结构的内存占用和操作性能。对于非常大的列表数据,可能需要考虑分页存储或者使用更适合大数据量的存储方式。有序集合在大数据量下,如果频繁进行插入和删除操作,可能会影响性能,需要根据实际情况进行优化。

缓存过期策略优化

  1. 精确过期时间设置
    • 基于业务逻辑:根据业务数据的更新频率来精确设置过期时间。例如,对于实时性要求不高的新闻资讯,缓存过期时间可以设置为几个小时甚至一天;而对于实时的股票价格数据,过期时间可能设置为几分钟甚至更短。
    • 动态调整:可以根据数据的访问频率动态调整过期时间。对于访问频繁的数据,适当延长过期时间;对于长时间未被访问的数据,提前过期以释放内存。
    • 代码示例(以 Python 动态调整过期时间为例)
import redis

r = redis.Redis(host='localhost', port=6379, db = 0)

def getValueWithDynamicExpiry(key):
    value = r.get(key)
    if value:
        # 增加访问计数
        visitCountKey = f'{key}:visit_count'
        r.incr(visitCountKey)
        count = int(r.get(visitCountKey))
        if count % 10 == 0:
            # 每访问10次,延长过期时间
            r.expire(key, 7200)
        return value.decode('utf - 8')
    else:
        # 从数据库获取值
        value = getValueFromDatabase(key)
        if value:
            r.setex(key, 3600, value)
            r.setex(visitCountKey, 3600, 1)
            return value
        return None

def getValueFromDatabase(key):
    # 实际从数据库获取值的逻辑
    return 'Sample value from database'
  1. 缓存预热
    • 原理:缓存预热是指在系统上线前或者在业务高峰来临前,提前将一些热点数据加载到缓存中,避免在业务运行时由于缓存未命中导致大量请求直接访问数据库。
    • 实现方式:可以通过脚本在系统启动时,从数据库中读取热点数据并写入 Redis 缓存。也可以根据历史数据和业务预测,定期更新缓存中的预热数据。
    • 代码示例(以 Shell 脚本实现缓存预热为例)
#!/bin/bash

redis-cli -h localhost -p 6379 del "product:1"
redis-cli -h localhost -p 6379 set "product:1" "$(mysql -u root -p -D mydb -e "SELECT * FROM products WHERE id = 1" | tail -n +2)"
redis-cli -h localhost -p 6379 del "user:1"
redis-cli -h localhost -p 6379 set "user:1" "$(mysql -u root -p -D mydb -e "SELECT * FROM users WHERE id = 1" | tail -n +2)"

Redis配置优化

  1. 内存配置
    • 合理分配内存:根据服务器的物理内存和应用需求,合理设置 Redis 的最大内存。可以通过 maxmemory 配置项来设置,例如设置为服务器物理内存的 70% 到 80%,避免 Redis 占用过多内存导致系统内存不足。
    • 内存淘汰策略:选择合适的内存淘汰策略,如 volatile - lru(在设置了过期时间的键中使用 LRU 算法淘汰键)、allkeys - lru(在所有键中使用 LRU 算法淘汰键)、volatile - ttl(淘汰即将过期的键)等。根据业务数据的特点和访问模式来选择,例如如果希望优先淘汰过期时间短的键,可以选择 volatile - ttl 策略。
  2. 网络配置
    • 绑定地址:通过 bind 配置项绑定 Redis 服务器监听的 IP 地址。如果只允许本地访问,可以绑定到 127.0.0.1;如果需要对外提供服务,需要绑定到服务器的公网 IP 或者 0.0.0.0(注意安全性)。
    • 端口:默认端口是 6379,可以根据需要修改为其他未被占用的端口,以提高安全性或者适应特定的网络环境。
    • TCP 配置:调整 TCP 相关配置,如 tcp - keepalive,设置为合适的值(如 60 秒),可以及时检测到客户端的异常断开,释放相关资源。

缓存架构优化

  1. 主从复制
    • 原理:主从复制是 Redis 提供的一种数据备份和读写分离机制。主节点负责处理写操作,并将写操作同步到从节点。从节点可以处理读操作,从而分担主节点的读压力。
    • 配置方法:在从节点的配置文件中,通过 slaveof 配置项指定主节点的 IP 和端口。例如 slaveof <master - ip> <master - port>。主节点不需要额外配置,只需要正常启动。
    • 优势:提高系统的读性能和可用性。当主节点出现故障时,从节点可以提升为主节点继续提供服务,保证系统的正常运行。
  2. 哨兵模式
    • 原理:哨兵模式是在主从复制的基础上,增加了对主节点的监控和自动故障转移功能。哨兵节点会定期检查主节点和从节点的状态,当主节点出现故障时,哨兵节点会自动选举一个从节点提升为主节点,并通知其他从节点和客户端。
    • 配置方法:需要配置多个哨兵节点,在哨兵节点的配置文件中,通过 sentinel monitor 配置项指定要监控的主节点信息,如 sentinel monitor mymaster <master - ip> <master - port> <quorum>,其中 <quorum> 表示判断主节点失效至少需要的哨兵节点数。
    • 优势:进一步提高了系统的可用性和稳定性,实现了主节点故障的自动处理,减少了人工干预。
  3. 集群模式
    • 原理:Redis 集群是一种分布式架构,它将数据分布在多个节点上,每个节点负责一部分数据的存储和读写。集群通过哈希槽(hash slot)来分配数据,共有 16384 个哈希槽,每个键通过 CRC16 算法计算出哈希值,再对 16384 取模,得到对应的哈希槽,从而确定该键应该存储在哪个节点上。
    • 配置方法:通过 redis - trib.rb 工具来创建和管理集群。例如,redis - trib.rb create --replicas 1 <node1 - ip>:<port1> <node2 - ip>:<port2>... 可以创建一个包含多个主节点和从节点的集群,其中 --replicas 1 表示每个主节点有一个从节点。
    • 优势:支持水平扩展,可以通过增加节点来提高系统的存储容量和处理能力。同时,集群模式也具备一定的容错能力,部分节点故障时,系统仍能正常运行。

通过对 Redis 在缓存设计中的应用和优化策略的深入理解与实践,可以构建出高性能、高可用且稳定的缓存系统,有效提升后端应用的整体性能和用户体验。在实际应用中,需要根据具体的业务场景和需求,灵活选择和组合各种优化方法,以达到最佳的缓存效果。