☰
Redis缓存实战:手写购物车系统,搞定Hash与TTL
2026/10/5 7:29:04 网站建设 项目流程

先聊点实在的。很多朋友一听到 Redis 就觉得是个“高并发神器”“大厂必备中间件”,还没开始学就先被术语吓住了。实际用到最后你会发现,Redis 最核心、最日常的定位就是五个字:快和KV 缓存。而购物车系统,恰好是理解“缓存到底怎么用”最接地气的业务场景。这篇文章我会老老实实从“缓存是什么”讲起,然后带你装好 Redis,弄懂它常用的数据类型,最后手写一个能跑的简易购物车系统,把加购、改数量、清空、过期这些环节全走一遍。适合完全零基础、被各种 Redis 面试题劝退过、以及想搞懂自己项目里缓存代码为什么这么写的人。

写这篇文章之前我特意翻了一圈网上关于 Redis 的教程,发现一个普遍问题:要么一上来就铺满命令,读者记不住;要么通篇讲分布式锁、集群,压根没解释业务里到底怎么落地。所以我用购物车这个场景把整条线串起来。你学完以后再看公司代码里别人写的HSet、Expire、LRU,基本都能对上号。

1. 缓存到底是什么:先把概念揉碎了再说

1.1 先从一次接口超时事故说起

假设你负责一个电商网站的商品详情页。平时数据库几万条商品数据,查询也就几十毫秒,并发不高,一切看起来岁月静好。结果某天大促来了,同一件爆款商品一秒被点了几千次,数据库连接池瞬间被打满,接口响应时间从 50ms 一路飙到 3 秒,链路监控里一片飘红。你一看慢查询日志,全是同一行 SQL:SELECT * FROM product WHERE id = 12345。

这个问题的本质是什么?数据库把数据存在磁盘里,每次查询都要经历一次磁盘 IO。虽然数据库自身有索引、有 Buffer Pool,但面对同一行热点数据被狂查几千遍,每次重复查库都是浪费。这时候在数据库前面加一个“快速查数据的暂存区”,把商品 12345 第一次查出来的结果放到内存里,后续同样的查询直接读内存,不再碰磁盘,扛住大促自然不在话下。这个暂存区,就是缓存。

我习惯用一个生活化的类比来解释:缓存就像你家门口的快件代收柜。快递员(请求)不用每次都往几公里外的仓库(数据库)跑一趟——把包裹先放在代收柜(缓存)里,你自己回家顺手就取了,又快又省事。只有代收柜没有你的包裹时(缓存未命中),快递员才会再去一趟仓库。

1.2 缓存的三种形态:浏览器缓存、本地缓存和分布式缓存

很多人第一次接触“缓存”,用的是浏览器的强制刷新和清缓存,但业务开发中的缓存通常分三层:

  • 浏览器缓存:资源文件(JS、CSS、图片)通过 HTTP 响应头里的Cache-Control、ETag等字段控制,让你第二次打开页面不用重新下载文件。
  • 本地缓存:应用进程内直接用一个Map或者 Guava Cache 存数据。好处是极快,零网络开销;坏处是每个服务实例各存一份,数据不一致,而且占用应用堆内存,重启即丢。
  • 分布式缓存:独立部署的缓存服务,应用通过网络读写,多个服务实例共享同一份缓存。Redis 就是这类里最出名的一个。

购物车这个场景,我必须选分布式缓存。原因很简单:今天你手机上加购的商品,明天在电脑上登录同一个账号,购物车也得在。如果购物车存在每台服务器自己的本地内存里,负载均衡把请求分到不同机器,用户一会看到购物车有 3 件商品,一会看到是空的,这体验谁受得了。所以购物车数据必须共享,Redis 这种分布式缓存正是为此而生。

1.3 Redis 到底凭什么这么快

Redis 常被称作“内存数据库”,它把数据存在内存里,读写不再碰磁盘,所以单机 QPS 能轻易到十万级,比 MySQL 的几千级高一个数量级。这“快”背后的支撑还有三点值得知道:

  • IO 多路复用:Redis 用单线程处理网络请求,配合 epoll 机制,一个线程就能同时照看成千上万个客户端连接,没有线程切换开销。这里的“单线程”指的是网络 IO 和命令执行在同一个线程里完成,所以指令是串行执行的,天然没有并发竞争问题。
  • 高效的数据结构:Redis 内置了字符串、哈希、列表、集合、有序集合等多种底层用 C 实现的精心设计的数据结构,查找、插入的时间复杂度几乎都是 O(1)。
  • 零拷贝与序列化优化:Redis 内部直接操作内存里的字节数组,不搞复杂的对象转换。

但你也先别把 Redis 神话。正因为它把数据放内存,所以内存容量有上限,数据可能因为宕机而丢失。后面讲购物车步骤时,我会专门强调哪些数据适合放 Redis、哪些必须回源查数据库。

2. Redis 入门关键:数据类型和基本命令

2.1 五种常用数据类型,一次讲清

Redis 的键都是字符串,值则有五种核心类型。很多新手抱怨记不住命令,其实是因为没把“数据结构”和“业务模型”对上号。我给你逐一拆开。

类型本质适合的业务场景一句话口诀
String单个字符串/数值验证码、Session、计数、分布式锁存单个值,最无脑
Hash一个 key 下的多个 field-value商品信息、购物车、用户资料一个对象字段多
List有序字符串列表消息队列、最新消息、浏览记录有顺序,能左右弹
Set无序不重复集合抽奖去重、共同好友、点赞天然去重
ZSet带分数的有序集合排行榜、延迟队列、热门商品有分值,能排序

一开始接触Set和ZSet时我也容易懵,后来发现一个分辨技巧:需要排序选 ZSet,只需要记录“谁在不在”选 Set,要维护一个先进先出或后进先出的队列选 List,要表示一个对象的多字段选 Hash。

2.2 Hash 和 String,购物车该用谁

购物车一个很典型的需求是:一个用户下面有多件商品,每件商品有数量。我见过不少新手上来就写:

SET cart:1001:sku_1001 1 SET cart:1001:sku_1002 3

这样把“商品 ID”拼进 key 里,确实也能存。但问题非常明显:想查“用户 1001 购物车有哪些商品”,你得先用KEYS cart:1001:*扫一遍 key。在真实生产环境里线上有几十万个 key 的时候,KEYS命令会阻塞整个 Redis,这是绝对的禁忌。

更合理的做法是直接用 Hash,一个 key 对应整个购物车,商品 ID 作为 field,数量作为 value:

HSET cart:1001 sku_1001 1 HSET cart:1001 sku_1002 3 HGETALL cart:1001

以后用户加购了 3 件商品,调一次HGETALL就能拿到全部,删除一件就HDEL cart:1001 sku_1001,整个行为非常清晰,不用跟“把 key 拼出来”较劲。Hash底层在字段数少时是 ziplist 紧凑存储,字段多时转成 hashtable,性能都有保证。这也是我推荐你用 Hash 做购物车而不是 String 的根本原因。

2.3 装好 Redis 并用客户端连上它

装环境是劝退高发区,实际没那么恐怖。Windows 用户可以直接去 GitHub 找 Redis 的 Windows 移植版;macOS 用户用 Homebrew 装最省事:

brew install redis redis-server /usr/local/etc/redis.conf

装好以后,建议马上装一个可视化客户端来观察数据变化。市面上一堆工具,我踩过不少坑后的结论是:Another Redis Desktop Manager(ARDM)免费、跨平台、功能顺手,已经是很多团队里的标配了。用它连接本机127.0.0.1:6379,建连后你就能直观看到刚才那些HSET写进去的 key 和 field。

平时调试倒是可以直接用命令行客户端:

redis-cli 127.0.0.1:6379> PING PONG

看到 PONG 就说明服务是活的。连不上的时候,先看端口是否被占用,再确认 Redis 配置文件里的bind是否限制了127.0.0.1,以及是否开了保护模式,这三步能解决 90% 的连接失败问题。

3. 手写购物车系统:从需求到完整实现

3.1 需求拆解和数据模型设计

一个“简易购物车系统”,我认为必须覆盖以下核心动作,这也是用户高频心智模型:

  1. 用户登录后能看到自己的购物车
  2. 用户能把某个商品加购,已存在的则累加数量
  3. 用户能修改购物车中某个商品的数量
  4. 用户能删除购物车中的某个商品
  5. 用户能清空整个购物车
  6. 如果用户长时间不操作,购物车里的数据能自动过期清理

数据模型我设计成:Hash类型 + key 用cart:{userId},field 用skuId,value 用数量整数。为什么这么设计,前面第 2 节已经论证过了。

考虑到真实电商系统中商品信息(标题、图片、价格)通常都存在 MySQL,购物车 Redis 里只存 skuId 和数量,需要展示时再回查商品服务拼装详情。这样做的主要原因有两个:一是 Redis 内存值钱,存太多冗余字段会平白浪费大量空间,而且商品标题、价格本身会变,存一份缓存还得处理更新;二是把“购物车数量关系”和“商品基础信息”解耦,符合单一职责。

3.2 加购操作:用 HINCRBY 一行搞定累加

加购逻辑最核心的点是“已存在就加数量,不存在就新增”。Redis 提供的HINCRBY命令完美覆盖了这个需求,不需要先 GET 再 SET 分两步走:

HINCRBY cart:1001 sku_1001 1

这个命令的意思是:给用户 1001 的购物车中编号sku_1001的商品数量增加 1。如果这个 field 不存在,它自动从 0 开始,增加后就是 1。整个过程是原子性的,即使两个请求同时加购同一件商品,也不会出现数量互相覆盖的问题。

对应到接口层,代码大致是这样的思路:

public CartDTO addToCart(Long userId, String skuId, Integer quantity) { String cartKey = "cart:" + userId; // 1. 更新购物车中的商品数量 Long newCount = redisTemplate.opsForHash().increment(cartKey, skuId, quantity); // 2. 重置购物车过期时间,延长用户活跃期 redisTemplate.expire(cartKey, 30, TimeUnit.DAYS); // 3. 返回最新的购物车数据 Map<Object, Object> cartItems = redisTemplate.opsForHash().entries(cartKey); return buildCartDTO(cartItems); }

这段代码里有一个很容易被忽略但非常关键的细节:Expire重置。因为 Redis 的 key 一旦被设置过期时间,我们每次修改数据后最好都重设一次 TTL,让购物车跟着用户活跃状态“续期”。如果一个用户半年没登录,购物车数据还占着内存,就属于无意义开销了,30 天后让它自动消失是合理策略。

3.3 查询、修改数量与删除:命令组合与思路

查询购物车:

HGETALL cart:1001

得到的结果是一串 field-value 对。在 Java 里,用entries方法拿到 Map 后,再根据每个 skuId 批量回查商品信息。

修改某一商品的购买数量,我会先用HGET检查一下商品是否在购物车中:

HGET cart:1001 sku_1001

如果返回 nil,直接提示用户“商品不在购物车”。如果存在,再执行HSET cart:1001 sku_1001 5覆盖数量。为什么不直接用HSET硬写?因为真实接口里,用户想改数量时我们得判断他传上来的数量是否超过了库存,也要避免对不存在的商品做无意义的写入,多一次检查能避免很多脏数据。

删除单件商品:

HDEL cart:1001 sku_1001

清空整个购物车,我建议直接删除这个 key,而不是遍历 field 再一个个删:

DEL cart:1001

注意HDEL和DEL区别。只删一件用HDEL,清空全部用DEL。如果还用HGETALL拿到所有 field 再删,那是自己给自己造工作量。

购物车里的商品如果因下架等原因已经无效,需要清理时,可以使用HSCAN命令安全地遍历 Hash 中的 field,而不要用HKEYS一把梭拉出所有内容。HKEYS在 field 数量很大的情况下同样可能阻塞 Redis,这是线上生产环境必须养成的肌肉记忆。

3.4 过期与续期:TTL 设计里的细节

购物车 key 的过期时间设置,我建议单点登录场景下从“用户活跃度”角度考虑。用户登录时购物车 key 挂上 TTL,每次加购、改购物车时重置;如果用户长期不活跃,到期后 Redis 自动删除。这种做法不需要写定时任务去扫数据,成本极低。

但这里有个坑:如果把所有用户购物车的过期时间都设置为完全相同的 30 天,那么当这些 key 同时过期时,Redis 删除操作会集中在同一时刻,产生“缓存雪崩”式的影响。线上解决方案是给过期时间加一个随机抖动:

// 基础30天 + 随机0到5天的额外时间 int baseSeconds = 30 * 24 * 60 * 60; int randomExtraSeconds = ThreadLocalRandom.current().nextInt(5 * 24 * 60 * 60); redisTemplate.expire(cartKey, baseSeconds + randomExtraSeconds, TimeUnit.SECONDS);

购物车数据丢失了能不能恢复?严格说 Redis 里没写 AOF/RDB 持久化的数据,宕机就可能丢。真实业务里更稳的做法是“写购物车时,往 MySQL 也落一份关联数据作为兜底,Redis 丢了可以回源 MySQL 重建”。简易系统先不用纠结持久化,但你心里要清楚:Redis 里的缓存理论上都可以丢,业务上丢不起的数据就要考虑双写或持久化。

3.5 完整接口示例(可直接抄作业)

我写一个极简但完整可用的 Spring Boot Controller,方法只保留核心逻辑:

@RestController @RequestMapping("/cart") public class CartController { @Autowired private StringRedisTemplate redisTemplate; // 获取购物车 @GetMapping("/{userId}") public Map<Object, Object> getCart(@PathVariable Long userId) { String key = "cart:" + userId; Map<Object, Object> items = redisTemplate.opsForHash().entries(key); if (items.isEmpty()) { return Collections.emptyMap(); } // 现实中这里根据每个 skuId 批量调用商品服务,补全商品标题、价格、封面图 return items; } // 添加商品 @PostMapping("/{userId}/add") public Map<Object, Object> add(@PathVariable Long userId, @RequestParam String skuId, @RequestParam(defaultValue = "1") Integer quantity) { String key = "cart:" + userId; redisTemplate.opsForHash().increment(key, skuId, quantity); // 续期,避免长期不活跃的用户占用内存 redisTemplate.expire(key, 30, TimeUnit.DAYS); return redisTemplate.opsForHash().entries(key); } // 修改数量 @PutMapping("/{userId}/update") public Map<Object, Object> update(@PathVariable Long userId, @RequestParam String skuId, @RequestParam Integer quantity) { String key = "cart:" + userId; Boolean hasKey = redisTemplate.opsForHash().hasKey(key, skuId); if (Boolean.FALSE.equals(hasKey)) { throw new RuntimeException("商品不在购物车中"); } redisTemplate.opsForHash().put(key, skuId, String.valueOf(quantity)); return redisTemplate.opsForHash().entries(key); } // 删除单件 @DeleteMapping("/{userId}/sku/{skuId}") public Boolean removeSku(@PathVariable Long userId, @PathVariable String skuId) { String key = "cart:" + userId; return redisTemplate.opsForHash().delete(key, skuId) > 0; } // 清空购物车 @DeleteMapping("/{userId}") public Boolean clear(@PathVariable Long userId) { return Boolean.TRUE.equals(redisTemplate.delete("cart:" + userId)); } }

之所以用StringRedisTemplate而不是RedisTemplate,是为了避免 JDK 序列化导致在 Redis 里存出乱码。如果你发现用可视化客户端看数据时 key 前面有一堆\x00\xac\xed开头的东西,就是序列化器配置错了。这个坑我见了不下十回,先用 StringRedisTemplate 就能省心。

4. 购物车玩明白之后,再谈缓存三大经典难题

4.1 缓存穿透:查一个根本不存在的商品

前面设计的是“商品数据在 Redis 里查不到就回源查 MySQL、查到了再写回 Redis”。但如果有用户疯狂查询一个不存在的商品 ID,比如GET 商品 999999999,Redis 永远查不到,请求每次都穿透到 MySQL,数据库同样会被拖垮。这就是缓存穿透。

购物车场景里同样存在:一个还没登录的用户尝试读取一个不存在的购物车 key,HGETALL返回空,后面如果跟着回源数据库查询,就会产生穿透。解决办法我列三种,按成本从低到高:

  • 缓存空值:在 Redis 里写一个空结果或占位值并设置短暂过期时间,比如几十秒,让后续相同查询直接命中空值缓存,不再穿透。
  • 参数校验:接口层直接拦截非法 ID,比如负数、超长字符串、格式不匹配的,直接拒绝。
  • 布隆过滤器:最优雅但相对重。把所有合法的商品 ID 放到布隆过滤器里,查询前先判断 ID 是否可能存在,不存在直接返回空。购物车的商品 ID 量级通常有限,一般用不到这一步。

4.2 缓存击穿:热点 key 过期的一瞬间

一个热点商品的缓存 key,一旦过期,突然来了大量请求,并发查数据库,MySQL 瞬间被打爆。这就是缓存击穿。购物车里对应的情况是:某个爆款商品被大家疯狂加购,商品详情缓存恰好失效。

业界又叫它“单点热点问题”,解决手段有三个方向。一是逻辑上永不过期,后台起定时任务主动刷新缓存;二是设置热点 key 的过期时间时加一个较长的随机值;三是最经典的:互斥锁,只让一个请求回源数据库,其他请求等锁。

用 Redis 做互斥锁时,注意不要用SETNX裸命令,因为可能会出现“持有锁的线程死了,锁永远不释放”的极端情况。正确姿势是:

SET lock:product_1001 1 EX 10 NX

NX表示只有 key 不存在时才设置成功,EX 10表示 10 秒后自动释放,防止死锁。拿到锁的线程查数据库并回填缓存,没拿到锁的线程 sleep 几十毫秒后重试。这个方案就是很多公司里 Redis 分布式锁的最小实现。注意释放锁的时候先比对 value 是不是自己的,避免误删了别人的锁。

4.3 缓存雪崩:大量 key 同时失效

雪崩和击穿的区别在于:击穿是单个热点 key,雪崩是大量 key 在同一时间段集体失效。原因可能是 Redis 宕机,也可能是大量 key 统一设置了相同的过期时间。

购物车场景里,虽然每个用户是独立的 key,但如果大家同一天注册,又把过期时间设成了完全相同的 30 天,那 30 天后的同一秒就会出现大量购物车 key 同时过期。这个前面提过“过期时间随机抖动”的解法,务必要结合到代码里。

更底层的一道保障是Redis 高可用。我见过有团队连主从和持久化都没开,Redis 一重启全部数据清空,业务方一脸茫然。生产环境至少做 RDB/AOF 持久化,配置好主从复制,条件允许再上哨兵或集群。这不是给新手徒增负担,而是因为缓存一挂,流量就直接冲垮数据库,那是机房级事故。

4.4 缓存一致性:先删缓存还是先更新数据库

购物车本身可以直接以 Redis 为权威数据源,但更普遍的场景是 Redis 缓存 MySQL 里的商品数据。这时就绕不开一个问题:商品价格改了,是先更新数据库还是先删缓存?这是面试高频题,也是线上事故高发地。我先给结论,再解释。

  • 先更新数据库,再删缓存,这种方式更稳妥。
  • 先删缓存,再更新数据库,会出现一个时间窗口:线程 A 删了缓存,还没改完数据库,线程 B 读到旧值写回缓存,缓存里就一直是旧数据了。

为什么不是“先更新数据库,再更新缓存”?因为写缓存动作本身不具备事务性,数据库更新成功后缓存更新失败,两边数据很难保证一致。删掉缓存反而是最简单的“失效”操作,下次读取时发现没有缓存,自动回源数据库,拿到的自然是最新的。

但“先更新数据库再删缓存”也有极端问题:删缓存时 Redis 刚好主从同步延迟,旧数据还没被清掉。真正彻底的做法是引入 binlog 订阅机制(如 Canal),靠数据库日志回调去删缓存,不在业务代码里手动维护一致性。简易系统别想太复杂,先做到“更新数据库后删除缓存”这一条,已经能覆盖绝大多数风险了。

5. 实战踩坑记录:从环境到线上,这些坑我替你趟过了

5.1 命令超时与连接报错怎么排查

在开发阶段,不少人第一次连 Redis 就会看到类似io.lettuce.core.RedisCommandTimeoutException或者redis command timed out。这个报错的排查顺序我建议固定住:

  1. 先ping一下 Redis 服务器,确认网络通不通。
  2. 再在服务器本机用redis-cli PING试一下,排除防火墙。
  3. Redis 默认绑定了127.0.0.1,如果你用另一台机器或者容器访问,必须改配置里的bind,同时设置requirepass密码,否则既连不上也不安全。
  4. 确认客户端连接池配置。比如 Lettuce 默认超时只有几秒,慢查询或者大 key 操作可能导致超时,适当调大timeout参数。

5.2 可视化工具选型与看 key 的执念

我特别建议刚入门的朋友一定装一个可视化客户端。命令行虽帅,但你对 Hash 里到底存了多少 field、TTL 还剩多久,心里没底时,看界面反而更快。除了前面说的 ARDM,开源的RedisInsight也挺好用,官方出品,更新活跃。它俩选一个就行,不建议装一堆,白白占内存还经常闹端口冲突。

很多时候你写完HSET,代码里显示成功,但打开可视化工具发现 key 没了。先别慌,检查三件事:是不是连错环境(本地和测试库)、key 是不是被改名了、是不是过期时间太短已经自动删了。排到最后,往往不是程序问题,而是环境问题。

5.3 缓存被“清掉”的错觉:持久化和恢复策略

有的朋友第一次在 Redis 里存数据,过几天打开发现 key 不见了,第一反应是缓存被系统清理了。其实大概率是 Redis 被重启了,或者你压根没开持久化。Redis 默认 RDB 持久化是开启的,但保存频率和策略不一定适合你的业务。为了尽量保证不丢数据,生产环境至少把appendonly yes打开,开启 AOF 持久化。

购物车这类数据如果全在 Redis,Redis 重启后全部购物车都空了,用户反映“车空了”,这锅背得很冤。我的经验是购物车这类关系型数据,要么定期把 Redis 里的 Hash 快照同步到 MySQL,要么直接以 MySQL 为准、Redis 做加速。简易系统学习阶段可以容忍丢,但理解这个风险很重要。

5.4 命令习惯:别再用 KEYS,记住 SCAN

写 Redis 实战代码时,最容易被吐槽的一个习惯就是KEYS。它在 key 很多时会导致服务卡顿,原因在于它是全表扫描,并且阻塞 Redis 单线程。线上排查问题想看有哪些 key,必须用SCAN(游标式迭代)和HSCAN(针对 Hash),虽然写法麻烦一点,但完全不阻塞服务。这是我强烈建议内化成习惯的一条规定。

顺手给一段 Java 里使用SCAN的参考片段,把匹配出来的 key 做个示例:

Set<String> matchedKeys = new HashSet<>(); ScanOptions options = ScanOptions.scanOptions().match("cart:*").count(1000).build(); try (Cursor<byte[]> cursor = redisTemplate.executeWithStickyConnection( connection -> connection.scan(options))) { while (cursor.hasNext()) { matchedKeys.add(new String(cursor.next())); } }

别嫌这个接口繁琐,它保护的是 Redis 的响应能力。日常小项目你也许感觉不到差异,可如果你以后接手大流量的线上系统,就知道当时这个坏习惯会造成多大麻烦。

5.5 关于购物车系统后续还能扩展的方向

购物车系统本身并不复杂,但它是一块特别适合练手的试验田,值得继续扩展的场景包括:未登录用户的购物车怎么和登录后账号合并、购物车里的商品库存不足时怎么提示、下单后怎样从购物车移除已购买的商品、以及购物车数据怎样和消息队列配合做异步处理。每一条延展下去,都能把 Redis 的用法、分布式系统的常见问题串起来。

我个人在实际操作中最深的一个感受是:Redis 上手不难,难的是你时刻记得“缓存是有生命周期的”“缓存和数据库永远存在不一致的可能”。写代码的时候每一行命令都问一下自己:这个 key 什么时候写入的、什么时候过期、失效了会发生什么。把这套思考方式练出来,比背一百条命令都管用。

最后再分享一个小技巧:如果你正在学习,我建议你写一个测试脚本,模拟 1000 个用户同时加购同一件商品,看 Redis 的HINCRBY会不会出现数量错乱。把这个实验做完,你对 Redis 原子性和并发安全性的理解会一下子立起来。到这一步,你就不再是“零基础”,而是真正入门了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询