老实讲,Redis 这个关键词在工程圈里已经热了十来年,但真正让我觉得“这个东西其实没那么难”的瞬间,恰恰是几年前给一个电商项目做购物车模块的时候。购物车有个极其刁钻的特点:读写频率高、并发量不小、数据结构不算复杂但格式很杂——刚好就是 Redis 这类内存型存储的甜点区。那个项目做完之后,我陆续带过好几个零基础的同事和学员,发现大家最大的障碍其实不是 Redis API 本身,而是一上来就被“缓存”这个抽象概念给绕晕了。所以这篇就用购物车当手术刀,把缓存是什么、Redis 怎么装、数据类型怎么选、购物车系统怎么从零手写出来,完整走一遍。适合刚接触后端、想搞懂缓存本质、或者准备面试被问到 Redis 数据类型和缓存穿透时能从原理上回答的人。
1. 从“缓存到底是什么”说起
1.1 用一家小卖部理解缓存
我习惯拿小卖部来解释缓存:你经常买的可乐,老板会放在收银台旁边的冰柜里,伸手就能拿;不常买的酱油,放在货架最里面,得走几步去找。这个“收银台旁边的位置”,就是缓存——它存的还是同一瓶可乐,但存取速度不一样。
计算机世界里的缓存也是这个逻辑。数据源头在数据库(硬盘或者远程服务),每次请求都去数据库拿,就好比每次都跑去货架深处找货。如果请求量小倒无所谓,一旦流量上来,数据库就成了瓶颈。于是我们在数据库前面加一层读写飞快的内存存储,把高频访问的数据复制一份放进去,请求先找内存,命中就直接返回,没命中再回数据库。
这个思路没有任何高深之处,但它揭示了缓存的两个核心价值:提速和抗压。提速是对用户而言的——响应时间从 50ms 降到 5ms;抗压是对数据库而言的——同样一台数据库,原本每秒能扛 1000 次请求,加了缓存后可能 900 次都打在缓存上,数据库只剩 100 次真实压力。
1.2 本地缓存、分布式缓存与浏览器缓存的区别
很多人第一次接触缓存,会被各种术语搞混。本地缓存(比如 Java 的 HashMap、Guava Cache、Caffeine)就是进程内部的一块内存,快是真快,但换个服务实例就谁也不认识谁。浏览器缓存则是把静态资源存在用户电脑上,连网络请求都省了。Redis 属于分布式缓存——它是独立部署的一个服务,多个应用实例都可以连上它,共享同一份缓存数据。
这三者的选择逻辑也很直白:单机调试用本地缓存最省事;一旦系统部署了多台机器,需要大家共享同一份数据,就得上分布式缓存。购物车就是典型例子:用户在 A 机器上加购,刷新页面请求可能被负载均衡转发到 B 机器,如果购物车数据存在各自机器的本地内存里,A 机器存了、B 机器查不到,用户就以为加购失败了。Redis 这种集中式的存储,天然避开了这个问题。
1.3 Redis 到底是什么:一个跑在内存里的键值数据库
Redis 的全称是 Remote Dictionary Server,翻译过来就是“远程字典服务”。你可以把它理解成一个超级大的 HashMap,但是这个 HashMap 跑在独立的服务器上,支持网络访问,而且它的 value 不止是字符串,还有哈希、列表、集合等结构。
这也是 Redis 和 Memcached 这类纯缓存方案最大的差异。Memcached 也能做缓存,但 value 基本就是字符串,你要存一个商品的名称、价格、库存、图片地址,得自己拼 JSON 字符串或者序列化,取出来再反序列化,麻烦且低效。Redis 本身就提供了 Hash 结构,可以直接对字段做增删改查,不需要整存整取。
当然,Redis 的能力边界也要说清楚:它是内存数据库,数据主要存在内存里,因此容量受限于机器内存,不适合存海量历史数据,也不擅长复杂的关联查询和事务性要求极高的场景。它离“数据库的数据库”还有距离。它是缓存工具,是加速器,不是说有了 Redis 就能扔掉 MySQL。
2. 为什么购物车系统选择 Redis:数据类型选型是关键
2.1 五种基本数据类型速览
新手学 Redis,最该先过的坎就是数据类型。Redis 的 value 有五种最基础的类型,记住一个类比就行:String 是口袋、Hash 是抽屉、List 是传送带、Set 是投票箱、ZSet 是排行榜。
- String:最通用的类型,可以存字符串、数字、JSON 序列化结果,适合做计数器、session、简单的键值对。
- Hash:一个 key 下面挂多个 field-value 对,适合存对象。比如商品的信息:商品ID下面挂名称、价格、库存。
- List:有序、可重复,支持从两端压入弹出,适合消息队列、最新列表。
- Set:无序、不可重复,适合去重、交集并集运算,比如共同关注。
- ZSet:有序集合,每个成员带一个分数,适合排行榜、延时队列。
2.2 为什么购物车不用 String 而用 Hash
购物车的数据模型天然是个嵌套对象:一个用户有一个购物车,购物车里有多个商品,每个商品有数量、选中状态等属性。如果用 String 存,典型做法是把整个购物车序列化成 JSON 字符串,塞进一个 key。搜索热门词里面有"redis序列化",网上很多教程也是这么教的,但实际做项目你会发现几个坑:
第一,并发修改粒度太粗。用户加购一个商品,正常逻辑是“读整个购物车 -> 改其中一个商品数量 -> 写回整个购物车”。如果两个请求同时在改,后写的会把先写的覆盖掉,这就是经典的并发覆盖问题。你只改了商品 A 的数量,另一个请求改了商品 B 的选中状态,最后写的那个会把对方的数据冲掉。用 Hash 结构,每个商品是购物车 key 下面的一个 field,你只操作那一个 field,天然避免了这种互相覆盖。
第二,网络开销不对称。用户只想改数量,你却得把整个 JSON 序列化、传输、反序列化、改完再序列化写回,多出来的流量和时间都是白花的。
第三,类型和语言的 mismatch。JSON 里取出来的数量是字符串,你还得自己 parseInt,用 Redis 的 Hash 配合 HINCRBY 可以对数字字段做原子增减,连读改写这个完整流程都省了。
所以购物车用 Hash,key 是用户维度,field 是商品ID,value 是商品条目信息(通常是一个小 JSON,包含数量、规格等),这个设计在我做过的几个项目里都是最优解。
2.3 命令层面的实战开胃菜
在没装 Redis 之前,先把命令混个脸熟。购物车场景最核心的 Hash 命令就这么几个:
# 添加或更新购物车中某个商品 HSET cart:user:1001 sku:2001 '{"skuId":2001,"name":"无线鼠标","price":89,"count":1}' # 取某个商品 HGET cart:user:1001 sku:2001 # 取整个购物车 HGETALL cart:user:1001 # 给商品数量加 1 HINCRBY cart:user:1001 sku:2001_count 1 # 移除某个商品 HDEL cart:user:1001 sku:2001 # 判断购物车里有没有某个商品 HEXISTS cart:user:1001 sku:2001这里有个小细节:field 的值如果存的是 JSON 字符串,HINCRBY 就用不了,因为 HINCRBY 只对整数有效。实际项目里我通常会拆两个维度:sku:2001 存商品静态信息(名称、价格、图片),sku:2001:count 存数量,这样能直接用 HINCRBY 做原子递增,不必读改写整个 JSON。后面手写购物车系统的时候,我会把这个设计讲得更细。
3. 环境准备:把 Redis 跑起来
3.1 Windows 和 Linux 上常见的安装方式
说到安装,很多人会卡在 Windows 上。Redis 官方其实不提供 Windows 原生版本,Windows 上能用的发行版基本都是微软老版本移植或者第三方编译的,版本普遍偏旧。我的建议是:有条件就用 Linux(哪怕是虚拟机或者云服务器),没条件就在 Windows 上用 Docker 装 Redis,这样版本可控、环境干净。
Linux 上最省事的安装方法是 apt 或者 yum:
# Ubuntu / Debian sudo apt update sudo apt install redis-server # 启动并验证 sudo systemctl start redis-server redis-cli ping # 返回 PONG 说明启动成功如果嫌系统包管理器版本太旧,想装官方最新稳定版,可以走源码编译:
wget https://download.redis.io/releases/redis-7.2.4.tar.gz tar xzf redis-7.2.4.tar.gz cd redis-7.2.4 make sudo make install redis-server --daemonize yesWindows 用户如果不想折腾虚拟机,Docker 是你的好朋友:
docker run -d --name redis-test -p 6379:6379 redis:7.2这条命令会拉取 Redis 7.2 镜像并在 6379 端口启动。注意一个坑:这样起的 redis 默认没有密码、没有持久化配置,只适合本地学习调试,千万别用在生产环境裸奔。生产环境的 Docker 启动命令至少要挂载数据卷、开启持久化、指定 requirepass,这些后面的小节会提到。
3.2 可视化客户端:Another Redis Desktop Manager
终端用 redis-cli 是基本功,但日常开发调试购物车数据的时候,可视化客户端能省掉大量时间。搜索热词里出现了 "redis desktop manager" 和 "another redis desktop manager",我强烈推荐后者——它原名 RedisDesktopManager,后来改成了 Another Redis Desktop Manager,简称 ARDM。
有一个很大的坑我要专门提一下:网上搜"redis desktop manager",很容易下载到来路不明的破解版或捆绑软件。这个工具本身是免费开源的,直接从 GitHub 的 releases 页面下载对应平台安装包就行。
连接配置很简单:填上 Redis 的 IP 或域名、端口(默认 6379)、密码(如果配置了 requirepass)。连上后,你会看到所有 key 的列表,点开一个 Hash 类型的 key,右侧会以表格形式展示 field-value 对,加购、改数量、删商品这些操作可以直接在界面上点按钮完成,对调试购物车逻辑特别方便。
3.3 配置细节:连接、密码和序列化
Redis 默认配置里有几个生产环境必须改的项。第一个是绑定地址,默认bind 127.0.0.1只允许本机访问,如果你要把 Redis 作为远程缓存服务,需要改成内网 IP 或者注释掉这行,同时开启保护模式。第二个是密码,在 redis.conf 里找requirepass,设置一个强密码。第三个是持久化,Redis 默认有两种持久化方式:RDB(快照)和 AOF(追加日志)。购物车数据如果丢了,用户会非常生气,所以至少把 AOF 打开,配置项是appendonly yes。
还有一个序列化的概念。很多人在搜索"redis序列化",其实主要指两种场景:一是客户端编程时,把对象序列化成 JSON 字符串存进去;二是 Spring Data Redis 这类框架在存储前会对 key 和 value 做序列化处理,默认的 JDK 序列化方式存进去是一堆二进制乱码,阅读性极差。我的习惯是:如果项目用了 Spring Boot,会把 key 的序列化器改成 StringRedisSerializer,value 统一用 GenericJackson2JsonRedisSerializer,这样可视化工具里看到的是正常人能读懂的 JSON,而不是乱码。
4. 手写一个简易购物车系统
4.1 需求梳理与数据模型设计
做项目的第一步不是写代码,而是把需求拆清楚。一个简易购物车系统至少要满足这些功能:加入购物车、修改商品数量、删除商品、查看购物车列表、清空购物车。如果做的是登录态系统,购物车还要跟用户绑定;如果做的是未登录状态,一般会用设备 ID 或者临时 token 绑定。
数据模型设计直接沿用前面的 Hash 方案:
- key:
cart:{userId} - field:
{skuId},value存的是商品静态信息的 JSON(名称、价格、图片等) - 辅助 field:
{skuId}:count,value存商品数量,用 HINCRBY 原子递增
这样设计的理由在前面 2.2 已经说透了:静态信息与数量分离,避免频繁修改数量时反复读写整个商品信息 JSON;HINCRBY 原子操作,天然解决并发覆盖问题。
4.2 购物车操作:添加、删除、修改、查询
我把核心操作展开成 Redis 命令和 Python 代码两层。先看命令层。
加入购物车分两种情况:购物车里已有这个商品,数量加一;没有这个商品,先写商品信息,再初始化数量为 1。写成命令是:
WATCH cart:user:1001 HEXISTS cart:user:1001 sku:2001 # 如果存在 HINCRBY cart:user:1001 sku:2001:count 1 # 如果不存在 HSET cart:user:1001 sku:2001 '{"skuId":2001,"name":"无线鼠标","price":89}' HSET cart:user:1001 sku:2001:count 1这里用到 WATCH 是为了防止判断和写入之间别人改了状态。但实际生产中,我更推荐用 Lua 脚本把这个过程原子化,或者干脆直接 HSET 一个带数量的 JSON、然后用 INCR 类命令配合。初学者先把 WATCH 机制理解透就行,它就是乐观锁:监视 key,如果事务提交前 key 被改了,事务就不执行。
修改数量更简单:
HINCRBY cart:user:1001 sku:2001:count 1 # 加一件 HINCRBY cart:user:1001 sku:2001:count -1 # 减一件 # 如果减完数量小于等于 0,则删除该商品删除商品:
HDEL cart:user:1001 sku:2001 sku:2001:count查看购物车:
HGETALL cart:user:1001清空购物车:
DEL cart:user:10014.3 系统扩展:过期时间、库存校验和 session 关联
一个只能读写 Hash 的购物车还谈不上是系统。要让它有点实战价值,需要加三块东西。
第一块是过期时间。未登录用户的临时购物车,应该设置过期时间,比如 30 天。命令是EXPIRE cart:temp_device_abc 2592000。要注意的是 EXPIRE 针对的是整个 key,而不是某个 field。如果你想把 key 的过期时间自动续期,可以在每次写操作后重新 EXPIRE 一次,这适合“用户每次加购都重置购物车生命周期”的场景。
第二块是库存校验。加购商品前要判断库存是否足够,否则用户把 100000 件睡衣塞进购物车,结算时才发现根本没货,体验极差。库存数据一般存在数据库里,购物车系统加购时先回源查一次库存,如果商品加入了 Redis 缓存,就从缓存查,但要注意缓存和数据库的库存一致性。购物车本身只记录用户意愿,不锁库存——真正扣减库存发生在下单时,不要在加购时扣库存,否则用户只加购不结算,库存就被白白占住了。
第三块是 session 关联。登录用户在 Redis 里的 key 是 cart:{userId},那未登录用户呢?常规做法是前端生成一个设备唯一标识,后端以cart:{deviceId}作为临时购物车 key,用户登录后把临时购物车合并到正式购物车再删除临时 key,这就是“登录合并”逻辑。
4.4 Python + Redis 完整代码示例
上面讲了这么多原理,最终要落到代码。我用 Python 示范一套最轻量的实现,用到的库是 redis-py。
import json import redis # 连接 Redis,注意生产环境不要硬编码密码 r = redis.Redis( host='127.0.0.1', port=6379, db=0, password=None, decode_responses=True ) def cart_key(user_id): return f"cart:{user_id}" def add_to_cart(user_id, sku_info: dict): sku_id = sku_info["skuId"] key = cart_key(user_id) # 用 HEXISTS 判断商品是否已在购物车 if r.hexists(key, sku_id): # 数量加 1,原子操作 r.hincrby(key, f"{sku_id}:count", 1) else: # 写入商品快照信息和初始数量 r.hset(key, sku_id, json.dumps(sku_info)) r.hset(key, f"{sku_id}:count", 1) def update_count(user_id, sku_id, delta): key = cart_key(user_id) count = r.hincrby(key, f"{sku_id}:count", delta) if count <= 0: # 数量减到 0 以下,删除商品 r.hdel(key, sku_id, f"{sku_id}:count") return count def remove_from_cart(user_id, sku_id): key = cart_key(user_id) r.hdel(key, sku_id, f"{sku_id}:count") def get_cart(user_id): key = cart_key(user_id) raw = r.hgetall(key) items = {} for k, v in raw.items(): if k.endswith(":count"): sku_id = k[:-6] items[sku_id]["count"] = int(v) else: items[k] = json.loads(v) return list(items.values())这段代码有几个可以拿来就用的细节:
decode_responses=True让 Redis 返回的字节串自动转成 str,否则你看到的全是 b'xxx'。hgetall一次取回所有 field-value,适合购物车商品数量不多的场景。如果商品几十上百个,建议用hscan分批迭代,避免大 key 阻塞 Redis。- 删除商品时同时删两个 field,不留孤儿数据。
- 数量减到非正数时自动清理,是购物车的基础卫生习惯。
实际项目比这个复杂的地方在于:商品信息会变化,购物车里的快照到底是旧价格还是新价格?一般购物车展示用快照(避免结算时价格跳动),结算时再回源取最新价格做校验。快照过期时间短的,就刷新一下;过期时间长的,就显示旧价然后提示。这里的取舍,就是 5.3 要讲的缓存一致性问题。
5. 缓存三大问题与购物车场景的结合
5.1 缓存穿透:购物车不存在的商品
搜索热门词里面 "redis缓存穿透" 出现频率非常高,这也是面试必考点。用购物车场景来解释最直观:用户加购的商品 ID 在商品库里根本不存在,比如前端被恶意篡改,提交了一个不存在的 skuId。你的系统先去 Redis 查,没查到;再去数据库查,还是没有;但这一次查询所消耗的时间已经在两个存储上都走了一遍。如果攻击者用几百个根本不存在的 ID 轮番请求,Redis 永远命中不了,全部请求都打到数据库,数据库直接被打挂。
三个经典解法:
- 缓存空值:数据库没查到也不直接返回,而是在 Redis 里存一个空对象或者 null 标记,设置很短的过期时间(比如 5 分钟),下次同样的请求直接命中空值缓存,不再穿透到数据库。
- 参数校验:在入口处直接过滤明显非法的 ID,比如 ID 不满足格式、长度不对、超出合法范围,直接拒绝,压根不用查库。
- 布隆过滤器:把所有合法商品 ID 提前放到布隆过滤器里,查询前先判断 ID 是否存在。因为布隆过滤器判定“不存在”是绝对准确的,说没有就是没有;判定“存在”则可能有误判,所以它只挡掉了那些真正不存在的 ID。这个方案适合商品数量大、ID 规则复杂、对空值缓存不友好的场景。
5.2 缓存击穿与缓存雪崩
缓存击穿和缓存雪崩是两兄弟,但机制不同。击穿指的是某一个热点 key在缓存过期的那一瞬间,大量请求同时涌进来,缓存一个都没有,所有请求全部穿透到数据库。雪崩指的是大量 key 同时过期,导致大范围请求穿透到数据库。
购物车场景里,击穿好比是一个爆款商品信息 key 过期了,瞬间几百个用户同时加购这个商品,全部回源查库,商品库瞬间压力暴涨。雪崩则可能是双十一零点,所有商品信息统一设置了 0 点过期,0 点一过,所有商品 key 同时失效,数据库承接洪峰。
对策上,击穿可以用互斥锁,也就是常说的分布式锁:当缓存过期时,只允许一个线程去数据库加载数据并重建缓存,其他线程等待这个线程完成后再走缓存。搜索热词里就有 "redis分布式锁",这是一个很大的话题,购物车场景简单应用就是:
def get_with_mutex(key, load_func, expire): data = r.get(key) if data: return data lock_key = f"lock:{key}" # SET NX 加锁,设置超时防止死锁 lock_result = r.set(lock_key, "1", nx=True, ex=5) if not lock_result: # 其他线程在重建缓存,稍作重试 time.sleep(0.1) return r.get(key) try: data = load_func() # 查数据库或其他慢操作 r.setex(key, expire, data) return data finally: r.delete(lock_key)雪崩的解法是控制过期时间:不要在同一个时间点让大量 key 过期。实现上最简单的做法是给缓存时间加一个随机数,比如expire = 基础过期时间 + random(0, 300),分散过期时间点。这对购物车的商品快照也很有效:商品信息本来可以缓存 1 小时,但可以加 0~5 分钟的随机偏移,避免整点大批量失效。
5.3 缓存一致性:购物车数据何时回源
缓存一致性永远是缓存系统的灵魂拷问。购物车商品信息单独存在 Redis 里,数据库里的商品价格改了,Redis 里的旧价格怎么办?两种情况:
- 强一致性需求:价格、库存这类对准确性敏感的数据,修改数据库时同步更新或删除 Redis key。业界有 Cache Aside 模式和延迟双删写法。我的实践经验是:先更新数据库,再删除缓存,删除失败就重试,或者让缓存自然过期。不推荐先更新缓存再更新数据库,因为一旦数据库更新失败,缓存里就留下脏数据。
- 最终一致性需求:商品名称、图片这类不太关键的信息,容忍短时间展示旧值,等缓存过期自然刷新就行。
购物车代码里为什么商品信息要存快照而不是直接存 skuId 然后每个请求实时去查?因为购物车列表接口高频查询,每次都回源数据库取 10 个商品的实时名称价格,性能会很差。快照可以让购物车列表秒开,结算时再重新校验价格库存。如果用户在购物车页停留了很久,价格已经变了,结算前校验的这一刻,把最新价格推给用户确认,这既保证了最终一致性,又没有牺牲列表性能和缓存收益。
6. 排查与调优:Redis 日志、慢查询与 Key 治理
6.1 从日志里看 Redis 是否有大请求
Redis 使用过程中出了问题,第一件事就是看日志和监控。Redis 默认日志文件在 redis.conf 里配置,logfile默认是空,直接输出到标准输出。生产环境一定改成文件,并且设置合理的 loglevel(debug、verbose、notice、warning)。
比较隐蔽的问题是 Redis 自身没有慢日志的概念吗?其实有,SLOWLOG GET可以查看执行时间超过阈值的命令。默认阈值为 10000 微秒,也就是 10ms,对于内存操作来说这已经很慢了。排查购物车接口变慢时,我会先跑一下:
redis-cli slowlog get 10看看是否有 HGETALL、HSET 这类命令耗时异常。如果有,基本可以断定是有大 key:一个购物车 key 存了几千个商品字段,HGETALL 一次取回大量数据,序列化和网络传输都成了瓶颈。
6.2 大 Key 和热 Key 的日常治理
电商购物车有个天然问题:极个别羊毛党用户的购物车可能有几千个商品,这就是大 key。大 key 会让 Redis 单线程处理命令时被阻塞,影响其他正常请求。处理办法:
- 用 hscan 分批扫描 Hash,不要一次 HGETALL。
- 给购物车设置商品数量上限,比如最多 200 个,超出就提示用户清减。这在产品层面也是合理的——正常人购物车不会装几百样东西。
- 定期扫描大 key,用
redis-cli --bigkeys做一轮初筛,找出超过阈值的 key 单独处理。
热 key 则是另一个极端:某个商品被大量用户加购,导致它的查询频率极高。Redis 单线程架构下,热 key 会占住 CPU,让其他请求排队。解法是给热 key 做本地缓存(应用服务器再加一层 HashMap)、或者把热 key 复制成多个带后缀的 key 分散到不同实例。
6.3 购物车系统的持久化策略选择
购物车数据允许丢吗?显然不允许,用户加了一堆东西你说没了,这体验就是劝退级。所以持久化不能关。
RDB 和 AOF 的取舍:
- RDB 是定期全量快照,恢复快但可能丢失最后一次快照之后写入的数据。
- AOF 是命令追加日志,每条写操作都记录,最多丢 append 到 fsync 之前的少量数据,但文件大、恢复慢。
- 混合持久化:Redis 4.0+ 支持,RDB 做全量基线 + AOF 做增量,兼顾恢复速度和数据安全。
购物车场景建议至少配置appendonly yes,同时appendfsync everysec,这样最多丢 1 秒内的写操作,对购物车来说完全能接受。如果业务要求更高,可以用 always 模式,但写入性能会明显下降,适合高价值场景。
7. 一点实操心得
回头看了看,从“缓存是什么”讲到手写购物车,再讲到缓存穿透、击穿、雪崩和一致性排查,这条线串下来,Redis 最核心的知识点基本都覆盖了。我个人感受最深的一步是从“看得懂命令”到“能自己设计 key 结构”的转变——你会开始思考每个业务实体应该用什么数据类型、key 怎么命名、过期时间怎么设置、数据不回源的情况下能容忍多久的延迟。这个思维习惯一旦建立,Redis 就不再是一个需要死记硬背命令的工具,而是一种自然的设计直觉。
最后分享一个小技巧:调试购物车的时候,别急着上可视化客户端,先用 redis-cli 把命令敲一遍,再在 Python 代码里用 redis-py 调一遍,最后再连可视化工具查看数据形态。这样三步下来,你对 Redis 数据结构的掌控感完全不一样。后面你再去接触分布式锁、延时队列、排行榜这些更复杂的应用,基础就都在这里了。