ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis缓存实战:手写购物车系统,搞定Hash与TTL

Redis缓存实战:手写购物车系统,搞定Hash与TTL 先聊点实在的。很多朋友一听到 Redis 就觉得是个“高并发神器”“大厂必备中间件”还没开始学就先被术语吓住了。实际用到最后你会发现Redis 最核心、最日常的定位就是五个字快和KV 缓存。而购物车系统恰好是理解“缓存到底怎么用”最接地气的业务场景。这篇文章我会老老实实从“缓存是什么”讲起然后带你装好 Redis弄懂它常用的数据类型最后手写一个能跑的简易购物车系统把加购、改数量、清空、过期这些环节全走一遍。适合完全零基础、被各种 Redis 面试题劝退过、以及想搞懂自己项目里缓存代码为什么这么写的人。写这篇文章之前我特意翻了一圈网上关于 Redis 的教程发现一个普遍问题要么一上来就铺满命令读者记不住要么通篇讲分布式锁、集群压根没解释业务里到底怎么落地。所以我用购物车这个场景把整条线串起来。你学完以后再看公司代码里别人写的HSet、Expire、LRU基本都能对上号。1. 缓存到底是什么先把概念揉碎了再说1.1 先从一次接口超时事故说起假设你负责一个电商网站的商品详情页。平时数据库几万条商品数据查询也就几十毫秒并发不高一切看起来岁月静好。结果某天大促来了同一件爆款商品一秒被点了几千次数据库连接池瞬间被打满接口响应时间从 50ms 一路飙到 3 秒链路监控里一片飘红。你一看慢查询日志全是同一行 SQLSELECT * 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数量作为 valueHSET 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 ManagerARDM免费、跨平台、功能顺手已经是很多团队里的标配了。用它连接本机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 需求拆解和数据模型设计一个“简易购物车系统”我认为必须覆盖以下核心动作这也是用户高频心智模型用户登录后能看到自己的购物车用户能把某个商品加购已存在的则累加数量用户能修改购物车中某个商品的数量用户能删除购物车中的某个商品用户能清空整个购物车如果用户长时间不操作购物车里的数据能自动过期清理数据模型我设计成Hash类型 key 用cart:{userId}field 用skuIdvalue 用数量整数。为什么这么设计前面第 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. 返回最新的购物车数据 MapObject, 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 MapObject, Object getCart(PathVariable Long userId) { String key cart: userId; MapObject, Object items redisTemplate.opsForHash().entries(key); if (items.isEmpty()) { return Collections.emptyMap(); } // 现实中这里根据每个 skuId 批量调用商品服务补全商品标题、价格、封面图 return items; } // 添加商品 PostMapping(/{userId}/add) public MapObject, 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 MapObject, 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 商品 999999999Redis 永远查不到请求每次都穿透到 MySQL数据库同样会被拖垮。这就是缓存穿透。购物车场景里同样存在一个还没登录的用户尝试读取一个不存在的购物车 keyHGETALL返回空后面如果跟着回源数据库查询就会产生穿透。解决办法我列三种按成本从低到高缓存空值在 Redis 里写一个空结果或占位值并设置短暂过期时间比如几十秒让后续相同查询直接命中空值缓存不再穿透。参数校验接口层直接拦截非法 ID比如负数、超长字符串、格式不匹配的直接拒绝。布隆过滤器最优雅但相对重。把所有合法的商品 ID 放到布隆过滤器里查询前先判断 ID 是否可能存在不存在直接返回空。购物车的商品 ID 量级通常有限一般用不到这一步。4.2 缓存击穿热点 key 过期的一瞬间一个热点商品的缓存 key一旦过期突然来了大量请求并发查数据库MySQL 瞬间被打爆。这就是缓存击穿。购物车里对应的情况是某个爆款商品被大家疯狂加购商品详情缓存恰好失效。业界又叫它“单点热点问题”解决手段有三个方向。一是逻辑上永不过期后台起定时任务主动刷新缓存二是设置热点 key 的过期时间时加一个较长的随机值三是最经典的互斥锁只让一个请求回源数据库其他请求等锁。用 Redis 做互斥锁时注意不要用SETNX裸命令因为可能会出现“持有锁的线程死了锁永远不释放”的极端情况。正确姿势是SET lock:product_1001 1 EX 10 NXNX表示只有 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。这个报错的排查顺序我建议固定住先ping一下 Redis 服务器确认网络通不通。再在服务器本机用redis-cli PING试一下排除防火墙。Redis 默认绑定了127.0.0.1如果你用另一台机器或者容器访问必须改配置里的bind同时设置requirepass密码否则既连不上也不安全。确认客户端连接池配置。比如 Lettuce 默认超时只有几秒慢查询或者大 key 操作可能导致超时适当调大timeout参数。5.2 可视化工具选型与看 key 的执念我特别建议刚入门的朋友一定装一个可视化客户端。命令行虽帅但你对 Hash 里到底存了多少 field、TTL 还剩多久心里没底时看界面反而更快。除了前面说的 ARDM开源的RedisInsight也挺好用官方出品更新活跃。它俩选一个就行不建议装一堆白白占内存还经常闹端口冲突。很多时候你写完HSET代码里显示成功但打开可视化工具发现 key 没了。先别慌检查三件事是不是连错环境本地和测试库、key 是不是被改名了、是不是过期时间太短已经自动删了。排到最后往往不是程序问题而是环境问题。5.3 缓存被“清掉”的错觉持久化和恢复策略有的朋友第一次在 Redis 里存数据过几天打开发现 key 不见了第一反应是缓存被系统清理了。其实大概率是 Redis 被重启了或者你压根没开持久化。Redis 默认 RDB 持久化是开启的但保存频率和策略不一定适合你的业务。为了尽量保证不丢数据生产环境至少把appendonly yes打开开启 AOF 持久化。购物车这类数据如果全在 RedisRedis 重启后全部购物车都空了用户反映“车空了”这锅背得很冤。我的经验是购物车这类关系型数据要么定期把 Redis 里的 Hash 快照同步到 MySQL要么直接以 MySQL 为准、Redis 做加速。简易系统学习阶段可以容忍丢但理解这个风险很重要。5.4 命令习惯别再用 KEYS记住 SCAN写 Redis 实战代码时最容易被吐槽的一个习惯就是KEYS。它在 key 很多时会导致服务卡顿原因在于它是全表扫描并且阻塞 Redis 单线程。线上排查问题想看有哪些 key必须用SCAN游标式迭代和HSCAN针对 Hash虽然写法麻烦一点但完全不阻塞服务。这是我强烈建议内化成习惯的一条规定。顺手给一段 Java 里使用SCAN的参考片段把匹配出来的 key 做个示例SetString matchedKeys new HashSet(); ScanOptions options ScanOptions.scanOptions().match(cart:*).count(1000).build(); try (Cursorbyte[] 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 原子性和并发安全性的理解会一下子立起来。到这一步你就不再是“零基础”而是真正入门了。
返回列表