关键词:Redis、数据结构、缓存、键值存储、后端开发、内存数据库、应用场景
摘要:Redis 作为后端开发中最常用的内存数据库,其核心竞争力在于“灵活且高效的数据结构”。本文将以“便利店经营”的故事为线索,用通俗易懂的语言揭秘 Redis 最常用的 7 类数据结构(String、List、Hash、Set、ZSet、BitMap、HyperLogLog),结合生活类比、底层原理、代码示例和实战场景,帮你彻底搞懂“为什么 Redis 能成为后端开发的‘万能瑞士军刀’”。
本文面向后端开发者,系统讲解 Redis 核心数据结构的设计逻辑、底层实现和实际应用。内容覆盖基础数据结构(String/List/Hash/Set/ZSet)、扩展结构(BitMap/HyperLogLog),并结合电商、社交、游戏等真实业务场景,解决“如何根据需求选择正确数据结构”的核心问题。
本文从“便利店经营”的故事引入,逐步拆解 Redis 各数据结构的“设计思路-底层实现-使用场景”,最后通过电商实战案例演示如何组合使用这些结构。
小红开了一家社区便利店,最近遇到了几个经营问题,她用 Redis 轻松解决了:
这些问题的解决方案,正是 Redis 最核心的 7 类数据结构!
String 是 Redis 最基础的数据结构,就像便利店的“存钱罐”——每个存钱罐(键)里只能装一样东西(值),但这个东西可以是数字、文本甚至图片(二进制数据)。
例子:用存钱罐“user:1001:score”存“张三的积分 1500”,需要时直接打开存钱罐取积分,速度极快(O(1) 时间复杂度)。
List 像便利店取餐区的“排队号码条”,按顺序记录一系列事件。可以从队头(左边)或队尾(右边)添加/删除元素,适合需要“按时间顺序处理”的场景。
例子:用“order:20231001”记录今天的订单,新订单永远加在队尾,查最新订单时直接取队尾即可。
Hash 像便利店的“带抽屉储物柜”,每个大柜子(键)有多个小抽屉(字段),每个抽屉存不同东西。比如“product:666”柜子里,“name”抽屉存“苹果”,“price”抽屉存“5元”。
例子:要查商品价格,不用翻整个柜子,直接打开“price”抽屉就能拿到,比 String 存多个属性更高效(不用拼接/拆分字符串)。
Set 像小朋友的“玩具盒”,里面装的玩具(元素)各不相同,而且打乱顺序随便放。适合需要“去重”或“快速判断是否存在”的场景。
例子:统计今天被加入购物车的商品,用 Set 存,重复添加“苹果”不会重复记录,查“是否加过苹果”时,看玩具盒里有没有就行。
ZSet 是“升级版的 Set”,每个元素(玩具)有一个“积分”(分数),玩具盒会根据积分自动排序。就像游戏里的“战斗力排行榜”,积分高的排前面。
例子:统计商品销量,“苹果”销量 100(分数 100),“牛奶”销量 120(分数 120),排行榜自动把“牛奶”放在“苹果”前面。
BitMap 是一个“二进制位的数组”,每个位置(位)只能是 0 或 1,像便利店的“会员签到月历表”。比如 30 天的月历表,第 5 天签到就把第 5 位标为 1。
例子:用“sign:user:1001”存用户 10 月签到情况,第 1 天签到→第 1 位=1,第 2 天没签→第 2 位=0,月底统计“全勤”时,看所有位是否都是 1。
HyperLogLog 是一个“概率型数据结构”,像便利店门口的“客流量估算器”。不用记录每个用户是谁,通过数学算法就能估算出“今天有多少不同用户来过”,误差率约 0.81%。
例子:用“uv:202310”统计 10 月访问用户数,即使有 1000 万用户,只需要 12KB 内存就能存下估算结果。
这些数据结构就像便利店的“经营工具箱”,不同工具解决不同问题:
Redis 数据结构的底层实现依赖多种编码方式,以适应不同场景的性能需求:
int
(整数)或 embstr
(短字符串,≤44 字节),大字符串用 raw
(动态字符串 SDS);ziplist
(压缩列表,节省内存),元素多用 linkedlist
(双向链表);ziplist
,字段多用 hashtable
(哈希表);intset
(整数集合),否则用 hashtable
;ziplist
,元素多用 skiplist
(跳表)+ hashtable
(双结构保证有序和快速查找)。Redis 的 String 底层用 SDS 实现,比 C 语言原生字符串更高效:
struct sdshdr { int len; int free; char buf[]; }
len
:当前字符串长度(O(1) 获取长度);free
:剩余空间(自动扩容,避免频繁内存分配);buf
:存储实际字符(末尾自动加 \0
,兼容 C 函数)。操作示例(Redis 命令):
# 存:设置用户积分
SET user:1001:score 1500
# 取:获取用户积分
GET user:1001:score # 输出 "1500"
# 改:积分+100(原子操作,线程安全)
INCRBY user:1001:score 100 # 输出 1600
ZSet 能快速排序(O(logn) 插入/删除),核心依赖 跳表 结构。跳表通过“多层索引”加速查找,类似“地铁的快线和慢线”:
跳表示意图:
第 3 层索引: 10 ---------> 30 ---------> 50
第 2 层索引: 10 -----> 20 -----> 30 -----> 40 -----> 50
第 1 层索引: 10 -> 15 -> 20 -> 25 -> 30 -> 35 -> 40 -> 45 -> 50
底层链表: 10,15,20,25,30,35,40,45,50(按分数排序)
操作示例(Redis 命令):
# 存:添加商品销量(分数=销量)
ZADD sales:rank 100 苹果 80 香蕉 120 牛奶
# 取:查销量前 2 的商品(降序)
ZREVRANGE sales:rank 0 1 WITHSCORES # 输出 "牛奶 120", "苹果 100"
# 改:苹果销量+20(原子操作)
ZINCRBY sales:rank 20 苹果 # 苹果分数变为 120
HyperLogLog 用“伯努利过程”估算基数(不同元素的数量),核心公式:
估算值 = α m ⋅ m 2 ⋅ ∑ j = 1 m 2 − R j \text{估算值} = \alpha_m \cdot m^2 \cdot \sum_{j=1}^m 2^{-R_j} 估算值=αm⋅m2⋅j=1∑m2−Rj
其中:
举例:向 HyperLogLog 中添加元素“apple”“banana”“apple”,实际基数是 2。HyperLogLog 通过哈希函数将元素映射到二进制串,统计每个桶的 ( R_j ),最后用公式估算出基数≈2(误差≤1%)。
跳表的查找、插入、删除操作时间复杂度为 ( O(\log n) ),与平衡树(如红黑树)相当,但实现更简单。
数学推导:跳表的层数服从几何分布,平均层数为 ( O(\log n) ),每一层最多遍历 ( O(1) ) 个节点,总时间 ( O(\log n) )。
某电商系统需要实现以下功能:
redis-py
(pip install redis
)。import redis
# 连接 Redis
r = redis.Redis(host='localhost', port=6379, db=0)
# 1. 商品详情页缓存(String)
def cache_product(product_id, detail):
# 缓存 1 小时(3600 秒)
r.set(f"product:detail:{product_id}", detail, ex=3600)
def get_cached_product(product_id):
return r.get(f"product:detail:{product_id}")
# 2. 用户购物车(Hash)
def add_to_cart(user_id, product_id, quantity):
# 购物车字段:product_id → 数量
r.hincrby(f"cart:{user_id}", product_id, quantity)
def get_cart(user_id):
# 获取所有商品及数量(返回字典)
return r.hgetall(f"cart:{user_id}")
# 3. 热门商品排行榜(ZSet)
def update_sales_rank(product_id, sales_increment):
# 销量增加(分数=总销量)
r.zincrby("sales:rank", sales_increment, product_id)
def get_top_sales(limit=10):
# 获取销量前 10 的商品(降序)
return r.zrevrange("sales:rank", 0, limit-1, withscores=True)
# 4. 秒杀库存扣减(String 原子操作)
def seckill_stock(product_id, required=1):
# 原子扣减库存(避免超卖)
# 返回剩余库存,若 <0 则扣减失败
return r.decrby(f"seckill:stock:{product_id}", required)
# 5. 用户连续签到统计(BitMap)
def sign_in(user_id, day_of_month):
# 第 day_of_month 天签到(从 1 开始)
r.setbit(f"sign:user:{user_id}", day_of_month-1, 1)
def check_sign(user_id, day_of_month):
# 检查第 day_of_month 天是否签到
return r.getbit(f"sign:user:{user_id}", day_of_month-1)
# 6. 月活用户数估算(HyperLogLog)
def record_uv(user_id, month):
# 记录用户访问(自动去重)
r.pfadd(f"uv:{month}", user_id)
def get_month_uv(month):
# 估算月活用户数
return r.pfcount(f"uv:{month}")
# 测试代码
if __name__ == "__main__":
# 缓存商品详情
cache_product(666, '{"name":"苹果","price":5}')
print("缓存的商品详情:", get_cached_product(666)) # 输出缓存的 JSON
# 添加购物车
add_to_cart(1001, 666, 2) # 用户 1001 买 2 个苹果
print("购物车内容:", get_cart(1001)) # 输出 {b'666': b'2'}
# 更新销量排行榜
update_sales_rank(666, 2) # 苹果销量+2
update_sales_rank(888, 3) # 香蕉销量+3
print("销量前 2:", get_top_sales(2)) # 输出 [(b'888', 3.0), (b'666', 2.0)]
# 秒杀库存扣减(初始库存设为 10)
r.set("seckill:stock:666", 10)
print("扣减后库存:", seckill_stock(666)) # 输出 9(剩余 9)
# 签到(10 月 5 日)
sign_in(1001, 5)
print("10 月 5 日是否签到:", check_sign(1001, 5)) # 输出 1(是)
# 记录月活
record_uv(1001, "202310")
record_uv(1002, "202310")
print("10 月预估月活:", get_month_uv("202310")) # 输出 2
set
存商品详情,设置过期时间避免内存泄漏;hincrby
原子增加商品数量,避免多线程并发问题;zincrby
原子更新销量,zrevrange
快速获取TopN;decrby
原子扣减库存,保证“库存-1”操作的线程安全;setbit
/getbit
按位操作,30 天签到仅需 4 字节内存;pfadd
自动去重,pfcount
用 12KB 内存统计百万级用户。数据结构 | 典型场景 | 优势 |
---|---|---|
String | 缓存(页面/API 结果)、计数器(点赞数/访问量)、分布式锁 | 原子操作(INCR/DECR)、支持二进制(存图片/序列化对象) |
List | 消息队列(生产者-消费者模型)、历史操作记录(最近 100 条日志) | 支持从两端读写(LPUSH/RPOP)、阻塞操作(BRPOP 实现等待) |
Hash | 用户信息(姓名/年龄/积分)、商品属性(名称/价格/库存) | 字段独立修改(HSET 单个字段)、节省内存(比 String 拼接更高效) |
Set | 标签系统(用户兴趣标签)、共同好友(交集运算)、去重统计(UV 临时存储) | 快速集合操作(SINTER/SUNION)、自动去重 |
ZSet | 排行榜(销量/战斗力)、时间线排序(按发布时间排序的动态) | 按分数排序(ZREVRANGE)、支持范围查询(ZRANGEBYSCORE) |
BitMap | 用户签到(连续 30 天)、活跃状态(365 天是否登录)、性别统计(男/女) | 内存极小(100 万用户签到用 122KB)、快速位运算(AND/OR 统计) |
HyperLogLog | 大基数统计(月活/日活)、广告点击统计(不同 IP 数) | 内存固定(12KB 统计 2^64 元素)、误差率低(≤0.81%) |
listpack
替代 ziplist
)减少内存占用;Redis 数据结构是“按需设计”的工具箱:根据“是否需要有序、是否需要唯一、是否需要统计”等需求,选择最合适的结构。例如:
Q1:Redis 数据结构的内存占用如何?
A:String 存储“1000”需约 40 字节(SDS 元数据+内容);Hash 存储 100 个字段比 String 拼接节省约 50% 内存;BitMap 存储 100 万用户签到仅需 122KB(1000000/8=125000 字节)。
Q2:如何选择 RDB 和 AOF 持久化?
A:RDB 适合“灾备”(文件小,恢复快),AOF 适合“数据安全”(记录每条写操作,误差≤1 秒)。生产环境建议同时启用。
Q3:Redis 集群如何分片?
A:Redis 集群用“哈希槽”(16384 个槽)分片,键通过 CRC16(key) % 16384
映射到槽,槽分布在不同节点。