前面写过了两篇 “Redis” 相关的内容,今天补一篇“基本功”内容,让后续折腾系列文章可以篇幅更短、内容更专注。

前言

在日常工作中,我们构建应用时总是离不开一些基础组件,Redis 就是其中特别常用的一个。之前我写过不少文章,通常会把多个组件放在一起讲,介绍它们是如何协同工作的。随着目前积累了上千篇内容后,这样的内容并不适合维护、也不方便大家查阅和学习。比如你今天只想看看 Redis 怎么用,却要在文章里翻来翻去找 Redis 相关的部分。

所以这次,我想换个思路,把这些基础组件分开来讲,一个一个地仔细说说。今天,我们就先聊聊 Redis。

为什么选 Redis 作为开头呢?因为它实在是太常用了。无论是做网站、做 App,还是做企业内部系统,都少不了它。缓存数据、管理用户会话、计数排行、消息队列…Redis 就像个百宝箱,总能帮我们解决各种各样的问题。

本文我们主要会聊四块内容:

  • Redis 的基础使用:常用的数据类型和命令使用
  • Redis 的实际使用:常用业务场景中的具体使用
  • Redis 的容器化使用(Docker 部署细节):靠谱的 Redis 环境
  • Redis 的最佳实践:一些基础建议

本文使用的是 2024 年下半年的最新版本 Redis 7.4.1。另外提一句,Redis 8.0 也快要来了,会带来不少令人兴奋的原本在企业版本中的新特性。比如内置的搜索功能、JSON 支持、时间序列数据处理等等。

好,话不多说,让我们先从基础开始吧!

一、Redis 简介

Redis(Remote Dictionary Server)是一个开源的内存数据结构存储系统,它可以用作数据库、缓存、消息代理和队列。我们可以将 Redis 内置的 丰富的数据类型分为核心数据类型和扩展数据类型两大类。

1. 核心数据类型

字符串(String):字符串是 Redis 最基础的数据类型。它不仅可以存储普通的文本,还可以存储数字、序列化的对象(二进制)等。单个字符串最大支持 512MB。

# 基本的设置和获取

# 设置键值对
SET user:1 "李逍遥"
# 获取值
GET user:1 # 返回: "李逍遥"

# 常用于缓存场景

# 设置过期时间(秒)
SETEX session:token123 3600 "user_session_data" # 3600秒后过期

# 原子递增/递减(适合计数场景)
SET article:1:views 0
INCR article:1:views    # 返回: 1
INCRBY article:1:views 10    # 返回: 11

多数字符串操作复杂度都是 O(1),SUBSTRGETRANGESETRANGE 复杂度可能是 O(n)。

列表(List):是按插入顺序排序的字符串列表。可以用来实现队列、栈或者最新动态这类场景,比如常用于消息队列场景。

# 添加元素
LPUSH notifications:user1 "你有一条新消息"  # 从左侧添加
RPUSH notifications:user1 "系统维护通知"    # 从右侧添加

# 获取列表内容
LRANGE notifications:user1 0 -1  # 获取所有元素
LRANGE notifications:user1 0 4   # 获取前5条消息

# 列表长度
LLEN notifications:user1

# 移除元素
LPOP notifications:user1     # 从左侧移除并返回
BLPOP notifications:user1 5  # 阻塞式弹出,最多等待5秒

# 列表修剪(保持固定长度)
LTRIM notifications:user1 0 99  # 只保留最新的100条消息

除了访问头部或尾部的列表复杂度是 O(1),其他的操作复杂度通常为 O(n)

集合(Set):是无序的唯一字符串集合。适合用于标签系统、关注关系等场景,可以高效地实现交集、并集操作。

# 添加元素
SADD user:1:interests "编程" "读书" "音乐"

# 删除元素
SREM user:1:interests "音乐"

# 判断元素是否存在
SISMEMBER user:1:interests "编程"  # 返回1表示存在

# 获取集合元素
SMEMBERS user:1:interests  # 获取所有元素
SCARD user:1:interests     # 获取元素数量

# 集合运算
SADD user:2:interests "编程" "游戏" "电影"
SINTER user:1:interests user:2:interests  # 共同兴趣(交集)
SUNION user:1:interests user:2:interests  # 所有兴趣(并集)
SDIFF user:1:interests user:2:interests   # 独有兴趣(差集)

集合最大大小为 2^32-1 (~40亿),除了 SMEMBERS 是 O(n) 复杂度外,基本都是 O(1)。

哈希(Hash):哈希表是“字段(Key)-值(Value)对”的集合,类似于 Python 字典或 Java HashMap。特别适合用来存储对象数据,比如用户信息、商品信息等。

# 设置单个字段
HSET user:1 name "李逍遥" age "25" city "临安"

# 获取字段值
HGET user:1 name      # 获取单个字段
HMGET user:1 name age # 获取多个字段
HGETALL user:1        # 获取所有字段和值

# 检查字段是否存在
HEXISTS user:1 phone  # 检查是否设置了手机号

# 递增数字字段
HINCRBY user:1 login_count 1  # 登录次数加1

# 删除字段
HDEL user:1 temporary_field

# 获取所有字段名或字段值
HKEYS user:1   # 获取所有字段名
HVALS user:1   # 获取所有字段值

每个哈希最大大小为 2^32-1 (~40亿),多数操作都是 O(1)。

有序集合(Sorted Set):每个成员关联一个分数的有序集合,成员唯一,分数可以重复。常用于排行榜、优先级队列等需要排序的场景。

# 添加带分数的元素
ZADD leaderboard 89.5 "player1" 95.2 "player2" 78.3 "player3"

# 获取排名
ZRANK leaderboard "player1"    # 从低到高排名(0开始)
ZREVRANK leaderboard "player1" # 从高到低排名

# 获取分数
ZSCORE leaderboard "player1"

# 获取排名范围
ZRANGE leaderboard 0 2 WITHSCORES  # 获取前3名(升序)
ZREVRANGE leaderboard 0 2 WITHSCORES  # 获取前3名(降序)

# 按分数范围获取
ZRANGEBYSCORE leaderboard 80 100 WITHSCORES  # 获取80-100分的玩家

# 增加分数
ZINCRBY leaderboard 5.0 "player1"  # 增加5分

大多数排序集合复杂度为 O(log(n) + m),n 是获取成员数量,m 是返回数据数量。

流(Stream):Stream 是 Redis 5.0 引入的数据类型,是追加式日志数据结构,用于记录和处理时序事件,支持消费者组模式。适合用于消息队列、事件流等场景。

# 添加消息
XADD events * type "login" user_id "123"  # * 表示自动生成ID

# 读取消息
XREAD COUNT 2 STREAMS events 0  # 读取最早的2条消息

# 创建消费者组
XGROUP CREATE events group1 $  # $ 表示从最新的消息开始

# 消费者组读取
XREADGROUP GROUP group1 consumer1 COUNT 1 STREAMS events >  # > 表示未被消费的消息

地理空间索引(Geospatial):支持存储地理位置信息,并提供经纬度距离计算、范围查询等功能,特别适合在位置服务中使用。

# 添加地理位置
# 语法:GEOADD key longitude latitude member
GEOADD locations 116.397428 39.909734 "中国科学院"
GEOADD locations 116.336956 39.986119 "清华大学"
GEOADD locations 116.310003 39.991957 "北京大学"

# 获取坐标
GEOPOS locations "中国科学院"  # 返回经纬度

# 计算两点间距离
# 单位可以是 m(米)、km(千米)、mi(英里)、ft(英尺)
GEODIST locations "中国科学院" "清华大学" km  # 返回距离,单位千米

# 获取指定范围内的位置
# GEORADIUS 以给定经纬度为中心查询
# GEORADIUSBYMEMBER 以已存在的位置为中心查询
GEORADIUS locations 116.397428 39.909734 5 km WITHCOORD WITHDIST
GEORADIUSBYMEMBER locations "中国科学院" 5 km WITHCOORD WITHDIST

# 获取 GEOHASH 编码
GEOHASH locations "中国科学院"

# 实际应用示例:寻找附近的商家
# 添加多个商家位置
GEOADD shops 116.397428 39.909734 "shop:1" 
GEOADD shops 116.396728 39.910734 "shop:2"
GEOADD shops 116.398428 39.908734 "shop:3"

# 查找某个位置 3km 范围内的商家,并返回距离和坐标
GEORADIUS shops 116.397428 39.909734 3 km WITHCOORD WITHDIST WITHHASH ASC

# 获取指定商家到其他商家的距离
GEORADIUSBYMEMBER shops "shop:1" 1 km WITHDIST

位图(Bitmap):支持对字符串进行位操作 ,适合高效存储布尔信息。适合用于记录状态、统计数据、用户行为统计等场景,可以大大节省内存。

# 设置位图的某一位
SETBIT user:login:2024-01-01 123 1  # 记录用户ID为123的登录状态

# 获取某一位的值
GETBIT user:login:2024-01-01 123

# 统计为1的位数
BITCOUNT user:login:2024-01-01  # 统计当天登录用户数

# 位操作
BITOP AND result user:login:2024-01-01 user:login:2024-01-02  # 连续两天都登录的用户

SETBITGETBIT 都是 O(1), BITOP 是 O(n),其中 n 是比较中最长的字符串的长度。

位域(Bitfield):可在字符串中高效编码多个计数器 ,支持原子性的获取、设置和递增操作 ,提供多种溢出处理策略。简单来说,我们可以在 Bitfield 中存储多个整数值,每个整数可以指定不同的位宽,非常适合存储大量小整数或计数器。

# 设置一个 8 位无符号整数(u8),偏移量为 0
BITFIELD counters SET u8 0 100  # 在位置0设置一个值为100的8位无符号整数

# 设置多个值
BITFIELD counters SET u8 0 100 SET u16 8 300  # 设置一个8位和一个16位整数

# 获取值
BITFIELD counters GET u8 0   # 获取第一个8位整数
BITFIELD counters GET u16 8  # 获取16位整数

# 递增操作(有溢出控制)
# 溢出策略:WRAP(回绕)、SAT(饱和)、FAIL(失败)
BITFIELD counters OVERFLOW SAT INCRBY u8 0 1  # 增加1,使用饱和溢出控制

# 一次性获取多个值
BITFIELD counters GET u8 0 GET u16 8

# 实际应用示例:记录每小时的计数器
# 假设要记录用户每小时的登录次数,使用 6 位整数(最大值63)
# 每个小时占用 6 位,一天 24 个小时总共需要 144 位
BITFIELD daily:logins:user123 SET u6 0 1    # 0点登录1次
BITFIELD daily:logins:user123 INCRBY u6 6 1 # 1点登录次数+1
BITFIELD daily:logins:user123 GET u6 12     # 获取2点的登录次数

BITFIELD 是 O(n),其中 n 是访问的计数器的数量。

2. 扩展数据类型(Redis Stack / Enterprise)

在 Redis 8.0 社区版本到来之前(8.0-M02 社区预览版包含这些模块),Redis Stack 和 Redis Enterprise 版本还提供了一些高级数据类型:

JSON

  1. 支持结构化数据存储
  2. 可直接操作 JSON 文档
  3. 支持复杂的查询操作
  4. 传递给命令的 JSON 值的深度限制最大为 128。

概率数据类型

  1. HyperLogLog:基数估算
  2. Bloom filter:成员检测
  3. Cuckoo filter:可删除元素的成员检测
  4. t-digest:百分位数估算
  5. Top-K:排名估算
  6. Count-min sketch:频率估算

时间序列

  1. 针对时间序列数据优化
  2. 支持高效的时间范围查询
  3. 适合监控和分析应用

因为 8.0 还没有正式发布,程序或许还有一些改进或变动的空间,所以我不计划在这篇文章中展开这三个类型的细节。

或许在后面的文章里,我会单独聊聊编译这些组件,以及实战的内容,就像是在之前使用 Redis 作为向量数据库使用一样:《使用 Redis 构建轻量的向量数据库应用:图片搜索引擎(一)》、《使用 Redis 构建轻量的向量数据库应用:图片搜索引擎(二)》。

二、Redis 实用场景

在介绍“核心数据类型”小节中,我提到了一些使用 Redis 的经典操作。就使用频率来说,最常见的场景还是缓存层(减轻数据库负载、提高响应速度、存储临时会话数据)、计数器(文章阅读量统计、用户点赞数、商品库存管理)、消息队列(任务排队、消息传递、事件处理)。

接下来我们来基于具体业务场景,再展开聊聊。

统一的登录态管理

又快到一年结束了,大家或许还有一些年假没有休,当我们提交年假的时候,可能需要先登录某个 OA 系统,然后再从 OA 系统跳转人力系统,然后提交年假使用申请,接下来就是等待年假审批通过,哦耶。

这个场景下,我们可能使用了多个系统,但是我们并不需要在系统间重复登录,因为系统会自动识别用户身份。这周无缝的用户体验,就可以使用 Redis 来实现。

具体实现方式是:当用户首次登录时,系统生成一个 Session ID,并将用户信息存储在 Redis 中。示例代码如下:

# 用户登录时
session_id = generate_unique_id()
user_data = {
    "user_id": "employee_001",
    "name": "小李",
    "department": "技术部",
    "permissions": ["oa_access", "hr_access"]
}

# 设置 session 数据,有效期 2 小时
redis_client.setex(f"session:{session_id}", 7200, json.dumps(user_data))

当用户访问其他系统时,这些系统可以通过共同的 Session ID 从 Redis 中获取用户信息,从而实现统一登录:

# 其他系统验证用户身份时
user_data = redis_client.get(f"session:{session_id}")
if user_data:
    user = json.loads(user_data)
    # 验证权限并允许访问

高并发场景下的缓存应用

每当有热门活动或者有大量集中访问高峰的时候,有一些系统就会变得很卡,尤其是需要大量数据库关联查询的场景。

比如每个月月初,大家集中向系统中查询上个月考勤是否有异常的时候,毕竟错过了补打卡还是挺麻烦的。这时,Redis 的缓存功能就派上了用场。

比如,对于考勤数据的缓存策略可以是这样的:

def get_attendance_report(employee_id, month):
    # 先尝试从 Redis 获取缓存的报告
    cache_key = f"attendance:{employee_id}:{month}"
    report = redis_client.get(cache_key)
    
    if report:
        return json.loads(report)
    
    # 缓存未命中,从数据库计算报告
    report = calculate_attendance_from_db(employee_id, month)
    
    # 将结果缓存到 Redis,设置适当的过期时间
    redis_client.setex(cache_key, 3600, json.dumps(report))
    return report

实时数据统计与分析

日常工作中,或许有的同学折腾过项目上线后的实时监控系统,来关注项目的各项指标,比如接口响应时间、用户访问量、错误率等。这些需要实时更新的计数器和统计数据,用 Redis 来处理再合适不过。

# 记录接口调用次数
redis_client.incr(f"api:calls:{api_name}")

# 记录接口响应时间
redis_client.lpush(f"api:response_times:{api_name}", response_time)
redis_client.ltrim(f"api:response_times:{api_name}", 0, 999)  # 只保留最近 1000 条

# 使用 HyperLogLog 统计独立用户数
# https://redis.io/docs/latest/develop/data-types/probabilistic/hyperloglogs/
redis_client.pfadd(f"api:unique_users:{api_name}", user_id)

任务队列与消息传递

现在的 OA 系统中,基本都具备审批功能。当用户提交了一个需要多人审批的任务后,系统需要自动发送邮件或通知,来通知下一个审批人。这种异步任务就可以通过 Redis 的列表结构来实现一个简单的任务队列:

# 添加审批任务到队列
task = {
    "type": "approval_notification",
    "document_id": "doc_001",
    "approver": "manager_001",
    "deadline": "2024-12-01"
}
redis_client.lpush("approval_tasks", json.dumps(task))

# 消费者处理任务
while True:
    # 获取并处理任务
    task = redis_client.brpop("approval_tasks")
    if task:
        task_data = json.loads(task[1])
        send_approval_notification(task_data)

限流与热点防护

假设我们的系统中,支持用户使用手机验证码来进行登录。为了避免短信发送过频繁,对用户造成骚扰,对平台成本造成浪费。通常我们可以使用 Redis 实现简单的访问频率限制:

def can_send(user_id):
    key = f"send_limit:{user_id}"
    # 获取用户最近的发送次数
    count = redis_client.get(key)
    
    if not count:
        # 第一次发送,设置计数器
        redis_client.setex(key, 3600, 1)
        return True
    
    count = int(count)
    if count > 10:  # 每小时最多发送 10 次
        return False
        
    # 增加计数器
    redis_client.incr(key)
    return True

实时排行榜

不少公司都有内部的学习平台,有不少都会展示内部课程的热度排行榜,包括最近一周最受欢迎的课程、学习时长最多的员工等。这种实时更新的排序场景,正好可以利用 Redis 的有序集合来实现:

# 记录课程学习人次
def record_course_view(course_id, user_id):
    # 课程热度加一
    redis_client.zincrby("hot_courses:weekly", 1, course_id)
    # 记录用户学习该课程的时间戳
    redis_client.zadd(f"course:viewers:{course_id}", {user_id: time.time()})

# 获取热门课程排行
def get_hot_courses(limit=10):
    return redis_client.zrevrange("hot_courses:weekly", 0, limit-1, withscores=True)

# 记录学习时长
def record_learning_time(user_id, minutes):
    # 累计学习时长
    redis_client.zincrby("learner:leaderboard", minutes, user_id)
    # 同时更新当日学习时长
    today = datetime.now().strftime("%Y%m%d")
    redis_client.zincrby(f"learner:daily:{today}", minutes, user_id)

地理位置服务

聊一个可能不少同学有体感的事情,上下班的时候需要掏出手机定位,打卡。当你超出公司办公室一定距离的时候,打卡是打不上的。这类“判断/查找空间点坐标距离”的需求,可以使用 Redis 的地理空间索引特性:

# 添加公司位置信息
def add_company_position(room_id, latitude, longitude):
    redis_client.geoadd("company", longitude, latitude, room_id)

# 检查公司是否在 200 米范围内
def check_nearby_company(latitude, longitude, radius=200):
    # 返回 200 米范围内的公司坐标,并附带距离信息
    return redis_client.georadius(
        "company", 
        longitude, 
        latitude, 
        radius, 
        unit="m", 
        withcoord=True, 
        withdist=True, 
        sort="ASC"
    )

智能表单与数据校验

有时候在做表单功能的时候,需要进行字段的实时验证,比如检查提交的项目编号是否唯一、员工编号是否有效等。使用 Redis 的集合可以高效地实现这类验证:

# 初始化有效的员工编号集合
def init_employee_ids():
    employee_ids = fetch_valid_employee_ids_from_db()
    redis_client.delete("valid:employee_ids")
    redis_client.sadd("valid:employee_ids", *employee_ids)

# 验证员工编号
def is_valid_employee(employee_id):
    return redis_client.sismember("valid:employee_ids", employee_id)

# 项目编号查重
def check_project_code(code):
    # 使用位图标记已使用的项目编号
    key = f"project:codes:{datetime.now().year}"
    # 将项目编号哈希到一个整数
    code_hash = hash(code) % (10 ** 6)
    exists = redis_client.getbit(key, code_hash)
    if not exists:
        redis_client.setbit(key, code_hash, 1)
    return not exists

接口限流与熔断

在一些场景中,我们需要进行一些流量控制。Redis 可以实现简单而有效的限流器:

class RateLimiter:
    def __init__(self, redis_client, service_name, max_requests, time_window):
        self.redis = redis_client
        self.service = service_name
        self.max_requests = max_requests
        self.time_window = time_window

    def is_allowed(self, api_name):
        key = f"ratelimit:{self.service}:{api_name}"
        current = self.redis.get(key)
        
        if not current:
            pipeline = self.redis.pipeline()
            pipeline.set(key, 1)
            pipeline.expire(key, self.time_window)
            pipeline.execute()
            return True
            
        current = int(current)
        if current >= self.max_requests:
            return False
            
        self.redis.incr(key)
        return True

# 使用示例
rate_limiter = RateLimiter(redis_client, "user_service", 100, 60)
if rate_limiter.is_allowed("get_user_info"):
    # 处理请求
    pass
else:
    # 触发限流处理
    pass

分布式锁实现

当我们的业务系统中出现订单处理的场景时,尤其是涉及到实体货物、票等资源等时候,需要确保不能超售、避免用户重复提交。可以使用 Redis 来实现一个简单的(分布式)“锁”:

class RedisLock:
    def __init__(self, redis_client, lock_name, expire_seconds=10):
        self.redis = redis_client
        self.lock_name = f"lock:{lock_name}"
        self.expire_seconds = expire_seconds

    def acquire(self):
        # 使用 setnx 实现加锁
        lock_value = str(uuid.uuid4())
        acquired = self.redis.set(
            self.lock_name, 
            lock_value, 
            ex=self.expire_seconds, 
            nx=True
        )
        if acquired:
            # 记录锁的值,用于安全释放
            self.lock_value = lock_value
            return True
        return False

    def release(self):
        # 使用 Lua 脚本确保原子性释放
        script = """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """
        self.redis.eval(script, 1, self.lock_name, self.lock_value)

# 使用示例
def process_order(order_id):
    lock = RedisLock(redis_client, f"order:{order_id}")
    if lock.acquire():
        try:
            # 处理订单
            pass
        finally:
            lock.release()

三、使用 Docker 运行 Redis

Redis 官方提供了社区版本的容器镜像,包含多种 CPU 架构。

1. 基础 Docker 命令

官方文档中的命令使用非常简单:

# 启动一个服务名称为 some-redis 的 Redis 容器
docker run --name some-redis -d redis

# 启用一个每 60 秒保存数据的,具备数据持久化的 Redis 容器
docker run --name some-redis -d redis redis-server --save 60 1 --loglevel warning

# 使用 Redis CLI 来链接 Redis 服务
docker run -it --network some-network --rm redis redis-cli -h some-redis

在 Docker 进入 27 版本后,不同系统的 Docker 默认行为发生了一些变化(比如是否会自动下载命令中未下载的镜像),所以我们从头开始:

# 拉取 Redis 官方最新镜像
docker pull redis:latest
# 日常使用更推荐固定版本的 Redis
docker pull redis:7.4.1-alpine3.20

# 启动 Redis 容器,将容器内的默认端口和宿主机端口打通
docker run --name my-redis -d -p 6379:6379 redis:7.4.1-alpine3.20

# 进入 Redis 容器,使用 Redis CLI 进入交互式终端
docker exec -it my-redis redis-cli

接下来,就可以使用上文中的命令进行练手或者实际业务啦。

2. 使用 Docker Compose

当然,为了方便管理,命令式的使用,显然没有声明式的配置来的靠谱,创建一个 docker-compose.yml 文件:

name: cache

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: my-redis
    ports:
      - "6379:6379"

这份配置文件实现了上面命令相同的功能,启动服务还是使用熟悉的命令即可:

docker-compose up -d

3. Redis 的数据持久化

Redis 提供两种持久化方式:

  • RDB(Redis Database):按指定时间间隔执行数据快照,适合备份场景。
  • AOF(Append Only File):记录所有写操作,提供更好的持久化保证。

而在 Docker 中基础的数据持久化也分为两种,一种是 Docker Volume,一种是文件挂载到磁盘。

使用 Docker Volume 进行 Redis 数据持久化:

name: cache

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: my-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

volumes:
  redis-data:

服务启动后 Redis 的数据将被存储在 Docker Volume 中,我们可以使用下面的命令查看 Volume 的细节:

# 获取 Redis 的数据 Volume
# docker volume ls | grep redis
local     cache_redis-data

# 使用 Inspect 命令查看具体信息
# docker volume inspect cache_redis-data
[
    {
        "CreatedAt": "2024-11-30T17:39:36+08:00",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "cache",
            "com.docker.compose.version": "2.29.7",
            "com.docker.compose.volume": "redis-data"
        },
        "Mountpoint": "/var/lib/docker/volumes/cache_redis-data/_data",
        "Name": "cache_redis-data",
        "Options": null,
        "Scope": "local"
    }
]

如果我们希望数据直接挂载到宿主机磁盘中,只需要做一些简单的改动:

name: cache

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: my-redis
    ports:
      - "6379:6379"
    volumes:
      - ./redis-data:/data

当服务运行后,我们就能够在当前目录的 redis-data 中看到 Redis 的数据啦。

如果我们希望使用 AOF 方式来替换 RDB 快照模式来存储数据(牺牲数据恢复速度,但是具备最好的数据可恢复性),可以继续修改配置:

name: cache

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: my-redis
    command: redis-server --appendonly yes
    ports:
      - "6379:6379"
    volumes:
      - ./redis-data:/data

4. Redis 服务自愈

如果我们希望服务能够在遇到异常自动恢复,并且不时的进行自检,判断是否需要进行服务自动恢复:

name: cache

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: my-redis
    ports:
      - "6379:6379"
    volumes:
      - ./redis-data:/data
    command: redis-server --appendonly yes
    restart: always
    healthcheck:
      test: ["CMD", "redis-cli","ping"]
      interval: 1s
      timeout: 3s
      retries: 5

除了进行服务自愈之外,还能够在我们使用 docker ps 检查服务状态的时候,提供更直观的状态展示(healthy):

# docker compose ps
NAME       IMAGE                    COMMAND                  SERVICE   CREATED         STATUS                   PORTS
my-redis   redis:7.4.1-alpine3.20   "docker-entrypoint.s…"   redis     2 seconds ago   Up 2 seconds (healthy)   0.0.0.0:6379->6379/tcp, [::]:6379->6379/tcp

很多时候,为了节约资源,我们会选择多个服务部署在同一台机器,Redis 相比较一些后起之秀,不支持将数据持久化到磁盘。随着数据增长,内存占用也会变大,为了避免无限制增长影响其他的业务,我们可以设置一个资源限制:

name: cache

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: my-redis
    ports:
      - "6379:6379"
    volumes:
      - ./redis-data:/data
    command: redis-server --appendonly yes
    deploy:
      resources:
        limits:
          memory: 10G # 限制内存使用
    restart: always
    healthcheck:
      test: ["CMD", "redis-cli","ping"]
      interval: 1s
      timeout: 3s
      retries: 5

5. Redis 和宿主机时间共享

有一些时候,我们会希望 Redis 和宿主机的时间保持一致,那么这个时候可以指定容器中的时区,并共享本机的时间和容器的时间:

name: cache

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: my-redis
    ports:
      - "6379:6379"
    environment:
      - TZ=Asia/Shanghai # 设置时区
    volumes:
      # 共享时间
      - /etc/localtime:/etc/localtime:ro
      - ./redis-data:/data:rw
    command: redis-server --appendonly yes
    deploy:
      resources:
        limits:
          memory: 10G
    restart: always
    healthcheck:
      test: ["CMD", "redis-cli","ping"]
      interval: 1s
      timeout: 3s
      retries: 5

6. Redis 配置细节调整

当我们面向特别的场景的时候,很多时候会基于 Redis 配置进行有倾向性的调整,来满足需求。假如我们要存 10 亿数据到 Redis 中,希望服务最多使用 32G 内存,可以使用下面的配置文件 redis.conf

# 内存配置
maxmemory 32gb
maxmemory-policy allkeys-lru

# 持久化配置
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# 连接配置
timeout 300
tcp-keepalive 60
maxclients 10000

# 安全配置
requirepass yourpassword

同时,可以对我们的配置进行一些调整,让 Redis 使用配置启动:

name: cache

services:
  redis:
    image: redis:7.4.1-alpine3.20
    container_name: my-redis
    environment:
      - TZ=Asia/Shanghai
    volumes:
      # 共享时间
      - /etc/localtime:/etc/localtime:ro
      - ./redis.conf:/etc/redis.conf:ro
      - ./redis-data:/data:rw
    command: redis-server /etc/redis.conf
    deploy:
      resources:
        limits:
          memory: 32G
    network_mode: "host"
    ulimits:
      nofile:
        soft: 65535
        hard: 65535
    restart: always
    healthcheck:
      test: ["CMD", "redis-cli","ping"]
      interval: 1s
      timeout: 3s
      retries: 5

最后,根据自己的情况调整系统配置:

# 在宿主机上执行
sudo sysctl -w net.core.somaxconn=65535
# 永久生效
echo "net.core.somaxconn=65535" >> /etc/sysctl.conf
sudo sysctl -p

四、最佳实践建议

使用 Redis 时,最需要注意的就是内存管理问题。

在使用的过程中,务必设置合理的内存上限,通常会建议设置为最大内存的 70%~80%,为系统和其他程序预留空间。

同时,内存淘汰策略的选择也很重要。如果只是使用它作为缓存,那么使用 volatile-lru 或 allkeys-lru 策略即可,如果用于重要数据存储,应该设置为不淘汰(noeviction)。当我们存储了太多数据之后,可以定期执行 MEMORY PURGE 来清理内存碎片。

当然,如果是缓存场景,设置合理的数据过期时间,避免数据长期占用内存能够节约大量的内存资源。如果查询大量数据,在能够使用 SCAN 的时候,尽量使用 SCAN 来替代 KEYS 命令。在默认的配置文件中异步删除过期数据的配置是关闭的(lazyfree-lazy-expire no),打开之后,可以降低对服务性能的影响。

在使用 Redis 命令时要特别注意避免 O(N) 复杂度的命令,比如 KEYS、FLUSHALL、FLUSHDB 等,这些命令在数据量大时会阻塞主线程。与此同时,应该使用批量命令来提升性能,比如用 MGET/MSET 替代多次 GET/SET 操作。以及 Pipeline 可以将多个命令打包一起发送给 Redis 服务,显著提升吞吐量。如果需要保证操作的原子性,可以使用 Lua 脚本,这样能够减少多次请求的网络往返消耗。

在进行持久化存储的时候,页要根据场景来进行选择,选择合适的策略和具体的配置项目。DB 持久化可以配置多个条件触发,比如 900 秒内至少有 1 个键被修改、300 秒内至少有 10 个键被修改、60 秒内至少有 1 万个键被修改等。AOF 持久化可以将 appendfsync 设置为 everysec,这样每秒刷盘一次,在性能和安全性之间取得平衡。

在《ThinkPad + Redis:构建亿级数据毫秒级查询的平民方案》的实战部分,我们提到过一些优化策略,也可以进行参考。至于主从和多机配置,如果读多写少,除了更换技术架构之外,读写分离是经典的策略。而多副本主从复制,则是大量在线业务主要考虑的选型。

最后,存储的键名设计,可以使用统一的前缀(如业务名:对象名:id),并尽量控制键名长度(缩写替代),避免使用特殊字符。连接 Redis 时使用连接池管理 Redis 连接,设置合理的超时时间,尽量在大量写入数据的时候使用异步处理。

总结

好了,不知不觉写了这么多,这篇文章就先到这里。

–EOF