ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Redis实战:用购物车系统讲透缓存、数据类型与穿透防护

Redis实战:用购物车系统讲透缓存、数据类型与穿透防护 老实讲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 可以对数字字段做原子增减连读改写这个完整流程都省了。所以购物车用 Hashkey 是用户维度field 是商品IDvalue 是商品条目信息通常是一个小 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 的序列化器改成 StringRedisSerializervalue 统一用 GenericJackson2JsonRedisSerializer这样可视化工具里看到的是正常人能读懂的 JSON而不是乱码。4. 手写一个简易购物车系统4.1 需求梳理与数据模型设计做项目的第一步不是写代码而是把需求拆清楚。一个简易购物车系统至少要满足这些功能加入购物车、修改商品数量、删除商品、查看购物车列表、清空购物车。如果做的是登录态系统购物车还要跟用户绑定如果做的是未登录状态一般会用设备 ID 或者临时 token 绑定。数据模型设计直接沿用前面的 Hash 方案keycart:{userId}field{skuId}value存的是商品静态信息的 JSON名称、价格、图片等辅助 field{skuId}:countvalue存商品数量用 HINCRBY 原子递增这样设计的理由在前面 2.2 已经说透了静态信息与数量分离避免频繁修改数量时反复读写整个商品信息 JSONHINCRBY 原子操作天然解决并发覆盖问题。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( host127.0.0.1, port6379, db0, passwordNone, decode_responsesTrue ) def cart_key(user_id): return fcart:{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_responsesTrue让 Redis 返回的字节串自动转成 str否则你看到的全是 bxxx。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 flock:{key} # SET NX 加锁设置超时防止死锁 lock_result r.set(lock_key, 1, nxTrue, ex5) 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默认是空直接输出到标准输出。生产环境一定改成文件并且设置合理的 logleveldebug、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 数据结构的掌控感完全不一样。后面你再去接触分布式锁、延时队列、排行榜这些更复杂的应用基础就都在这里了。
返回列表