ARTICLE DETAIL

资讯详情

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

Redis缓存实战:从数据结构到购物车系统开发

Redis缓存实战:从数据结构到购物车系统开发 大概每个开发团队都经历过这种时刻页面突然卡得不行数据库连接数飙满运维在群里发截图问“谁把线上数据库打挂了”。这时候十有八九会有人提议“上个缓存吧”然后再往下一聊就发现事情远比想象中复杂——缓存是什么缓存哪些数据为什么偏偏用 Redis购物车这种业务场景在 Redis 里到底该用什么数据结构存如果这些概念都是悬空的所谓“上缓存”就只是在系统里塞进一个更大的不确定性。这篇文章就从一个最务实的小目标出发以 Redis 为主线先把缓存到底解决什么问题讲透再带你搭一套能跑的 Redis 环境、把常用命令练熟最后直接上手写一个简单的购物车系统。整个过程贴近真实业务不绕理论不堆概念适合还没系统接触过 Redis、想快速从“听过名词”到“动手能用”的开发者。1. 缓存不是“多存一份数据”那么简单1.1 数据库扛不住流量时到底发生了什么先看一个特别常见的现象公司内部某个运营系统平时几百人用得好好的某天市场部搞活动流量突然翻了二十倍后台开始频繁报数据库连接超时。打开监控一看MySQL CPU 接近百分百慢查询日志里塞满了原本几十毫秒就能跑完的 SQL。这里的问题不在于 SQL 写得有多烂而在于请求量已经超出了数据库的吞吐能力。关系型数据库的数据存储在磁盘上即使有索引和内存缓冲每一条查询也要经过语法解析、查询计划、存储引擎访问这一整套流程。当大量请求同时打到数据库时连接池首先被占满新的请求只能排队等待排队时间一长就表现为超时同时缓冲池里的热数据频繁被换出磁盘 I/O 飙升整个系统进入恶性循环。我习惯用一个生活化的类比来解释缓存的价值数据库像一个楼下的小超市每次做饭缺葱姜蒜都得跑下楼买缓存则是你家冰箱周末囤一批常用的食材做饭的时候直接从冰箱拿省去来回跑腿的时间。程序访问数据也是同样的逻辑——把高频访问的数据放到离计算更近、速度更快的地方系统整体响应速度立刻就不一样了。1.2 缓存命中率一针见血的指标判断缓存到底有没有起效果最核心的指标是命中率Hit Rate公式非常简单命中率 缓存命中的请求数 ÷ 总请求数举个例子购物车接口每秒钟收到 1000 个请求其中 800 个请求能在 Redis 里直接拿到用户购物车数据另外 200 个请求需要穿透到数据库查询那么命中率就是 80%。命中率越高打到数据库的流量越低整体延迟也越稳定。理想状态下读多写少、访问集中的场景命中率能做到百分之九十五以上。决定命中率的有三个关键因素数据访问的局部性同一批热数据是不是被反复访问。比如一件刚上架的商品详情页必然被疯狂点击这类数据很适合缓存而一年才被翻一次的历史订单缓存它意义不大。过期时间设得是否合理TTLTime To Live设太短数据总在失效边缘反复横跳命中率上不去TTL 设太长数据变陈旧用户就会看到过期的价格和库存。缓存容量与淘汰策略Redis 默认有 maxmemory 限制超过容量后按 LRU最近最少使用等策略淘汰。如果内存太小热数据被频繁挤出命中率同样虚低。所以“上了缓存”不代表万事大吉你至少要能回答出“命中率现在是多少、为什么是这个数、要优化哪里”这三个问题否则缓存只能算自我安慰。1.3 为什么是 Redis 而不是程序里的一个全局 Map很多人第一次接触缓存时会冒出一个想法既然要一个又快又近的数据容器那我自己在服务里写一个全局MapString, String不就行了吗还不用连什么中间件零依赖。这个思路在单机单进程的小工具里的确够用但一旦系统横向扩展问题立刻暴露服务部署了三台机器每个进程手里都握着一份独占缓存用户在 A 机器写入了购物车下一次请求被负载均衡分到 B 机器B 机器上的缓存根本不知道这回事。Redis 之所以成为大多数系统的默认选择是因为它做了几个关键设计独立部署、多进程共享缓存数据不归属于任何一台业务服务器所有节点访问的都是同一份数据这才有了意义。纯内存操作加上高效的事件模型单线程处理命令省去了锁竞争的开销单实例读写能力可以达到每秒十万次以上。丰富的数据结构不只是 key-value还有 List、Hash、Set、ZSet可以覆盖队列、计数器、排行榜、对象存储等场景。持久化机制RDB 快照和 AOF 日志让缓存数据在进程重启后还能恢复不至于一重启就回到起点。原生的过期机制给每个 key 设置 TTL到期自动删除省得业务代码里自己写定时清理任务。这些特性叠加起来Redis 已经不是“高级一点的 Map”而是合格的分布式缓存中间件。明白了这一层再往下学具体命令就顺理成章了。2. 搭建 Redis 运行环境先把基础命令练熟2.1 安装别在 Windows 上死磕老版本Redis 本身是 Linux 平台上的项目官方并不原生支持 Windows。如果你在 Windows 上开发建议优先用 WSL 或者 Docker。先说 Docker 方式这也是最省事、最不容易污染本机环境的方案docker run --name redis-demo -p 6379:6379 -d redis:7跑起来后用docker exec -it redis-demo redis-cli ping验证返回PONG说明正常。如果你在 macOS 上一条命令就能装好brew install redis redis-server /usr/local/etc/redis.confLinux 服务器上更简单以 Ubuntu 为例apt update apt install redis-server -y systemctl start redis-server装好之后第一步建议设置密码。直接改 redis.conf 里的requirepass字段或者临时用命令设置redis-cli config set requirepass yourpassword本地学习环境也许嫌麻烦但只要是部署在云服务器上没有密码的 Redis 基本等于把端口裸奔在公网上被扫描到就是整库被写满垃圾 key 的下场这个习惯最好一开始就养成。这边顺便提醒一句网上很多“Windows 直接运行 Redis”的教程用的是第三方移植版版本长期停留在 3.x/5.x和官方 7.x 的行为有些出入。如果你只是为了本地跑通学习用 WSL 或 Docker 和线上的版本保持一致后面踩坑的几率会低很多。2.2 Redis 的几种数据类型分别适合什么场景学 Redis 最容易犯的错误是把它当成一个“存字符串的数据库”。实际上 Redis 五种基本数据类型的适用场景差异非常大我整理了一张对照表方便你按需选型类型底层结构典型场景常用命令示例String动态字符串缓存文本、计数、tokenSETGETINCREXPIREHash哈希表对象字段存储、购物车HSETHGETHGETALLHINCRBYList双向链表消息队列、最新动态列表LPUSHRPOPLRANGESet哈希集合去重、标签、共同好友SADDSISMEMBERSINTERZSet有序集合排行榜、延时队列ZADDZRANGEBYSCOREZINCRBY初学者最容易记住这一点String 存单值Hash 存对象List 存队列Set 存去重ZSet 存带排序的数据。具体到购物车这个需求上你要知道它能存的东西远不止“一条字符串”而购物车这种“一个用户对应多个商品、每个商品对应一个数量”的嵌套关系用 Hash 比用 String 舒服得多——原因留到第三章详细说。2.3 哈希类型的操作命令接下来要用到的全套既然是零基础入门命令这块我不追求面面俱到只把购物车即将用到的哈希命令完整跑一遍。先进入命令行redis-cli -a yourpassword模拟一个用户 ID 为10086的购物车他要把商品sku1001加购 2 件、sku1002加购 1 件HSET cart:10086 sku1001 2 HSET cart:10086 sku1002 1查看这个用户购物车里的所有商品和数量HGETALL cart:10086此时返回1) sku1001 2) 2 3) sku1002 4) 1如果用户又加购了这件商品千万不要先 GET 再 SET而要用原子自增命令HINCRBY cart:10086 sku1001 1执行后sku1001的数量直接变成 3。这个命令在并发场景下非常关键多个请求同时执行“读旧值、加一、写回”时如果手动操作就会丢更新而HINCRBY是在 Redis 内部完成的原子操作。查看某一个商品数量HGET cart:10086 sku1001删除某个商品HDEL cart:10086 sku1002检查购物车里有没有某件商品HEXISTS cart:10086 sku1001最后还有一条和购物车强相关的命令设置过期时间EXPIRE cart:10086 2592000意思是这个 key 在 30 天2592000 秒后自动消失。很多业务会把购物车保存周期设为 30 天只要用户这 30 天内有操作就续期超过 30 天未访问则自动清理。这一条命令省掉了业务里大量“定时删除不活跃购物车”的脏活。3. 购物车数据模型设计key、field、value 怎么定3.1 购物车业务的隐藏复杂性购物车看起来简单实际上手就发现坑不少。第一层是基础 CRUD添加商品、修改数量、删除商品、查询列表。第二层就麻烦一些同一个用户把同一件商品加购两次是合成一条还是出现两条每个商品数量能不能为负数购物车要不要为每个用户设置保存时效第三层往往被新手忽略——购物车里存的是“商品快照”还是“商品引用”如果用户加购时价格是 100 元第二天商家改价为 80 元用户打开购物车看到的应该是 80 元还是 100 元如果沿用关系型数据库的思路大概率会设计一张cart_item表字段包括user_id、sku_id、quantity、updated_at加购时先判断记录是否存在存在则 UPDATE不存在则 INSERT。这套思路在数据量小的时候没有任何问题但流量一大频繁的读写和行锁竞争就会成为瓶颈而且每次加购都要经过一次完整的 SQL 事务。换到 Redis 这边设计目标很明确把用户维度的读写变成一个 key 上的纯内存操作让每次加购、查询都能在亚毫秒级完成同时保证并发下的数量正确性。3.2 从二维表思路到 Redis 哈希结构我最推荐的做法是用哈希表来构建购物车映射关系如下keycart:{userId}代表一个用户的购物车field{skuId}代表商品value数量用命令表达加购就是HSET cart:user_10086 sku1001 1对应的结构直观到不需要额外说明。你可能会问为什么不用 String 把整个购物车序列化成 JSON 塞进去比如SET cart:10086 {sku1001:2,sku1002:1}。这种方案的问题在于每次加购都要把整个 JSON 取出来、反序列化、修改数值、再序列化、写回去。如果用户购物车里有二十种商品每次只想给其中一种加一件却要把全部二十种数据来回读写一遍更严重的是并发场景下 A 请求和 B 请求同时读到同一份 JSON各自修改后写回总有一个用户的修改会被覆盖丢失。把数据粒度拆分到 field 级别之后HINCRBY可以单独操作某一个 sku不用动其他字段也没有先读后写丢失更新的问题。这是 Hash 结构在购物车场景里最核心的优势。3.3 为购物车设计过期时间和更合理的 value有经验的设计者会在 key 上设置 TTL 来控制购物车的保存时间。常见策略是“滚动过期”用户每次操作购物车就调用一次EXPIRE把 TTL 重置为 30 天用户 30 天没动静购物车自动消失。这样做既释放了不再活跃的用户数据所占的内存也符合“购物车保存一段时间”的产品预期。value 方面购物车哈希里只存数量这一个字段不存商品标题、价格、封面图。原因很现实这些商品基础信息是易变数据由商品服务统一维护。如果把价格快照强行写进购物车商家改价后还得想尽办法全量刷新购物车里的价格否则用户看到的就是过时价格投诉一个接一个。更稳妥的做法是购物车只存“用户加购了什么、数量是多少”渲染购物车页面时再根据skuId批量回源到商品服务拿最新标题、价格和库存。这个设计隐含着一个原则缓存里别存和业务强相关、会频繁变化的字段能放 ID 就放 ID具体信息实时查。购物车用skuId做 field 正是这个思路既保证了数据准确又让 Redis 里的记录保持轻量。4. 用 Python 手写一个简易购物车系统4.1 先装好 redis-py 客户端环境就绪后用 Python 写代码效率最高也能让初学者把注意力集中在 Redis 命令的用法上。安装客户端库pip install redis然后建立连接。这里有一个零基础学员常常忽略的坑连接时如果不加decode_responsesTrueHGETALL返回的 key 和 value 都会是bytes类型打印出来全是bsku1001处理时还要手动 decode非常别扭。建议学习阶段一律打开自动解码import redis r redis.Redis( host127.0.0.1, port6379, passwordyourpassword, db0, decode_responsesTrue, )这样后续所有命令返回的结果都直接是字符串跟命令行里看到的保持一致。4.2 加购与数量的原子操作定义一个购物车操作类把 key 的拼接统一收口避免散落在各处形成约定事故。先实现加购class CartService: CART_KEY_PREFIX cart: TTL_SECONDS 30 * 24 * 3600 # 30天 def __init__(self, client): self.client client def _cart_key(self, user_id): return f{self.CART_KEY_PREFIX}{user_id} def add(self, user_id, sku_id, quantity1): if quantity 0: raise ValueError(quantity must be positive) key self._cart_key(user_id) self.client.hincrby(key, sku_id, quantity) self.client.expire(key, self.TTL_SECONDS) return True加购两件就是cart.add(user_10086, sku1001, 2)。hincrby会默认把不存在的 sku 当作 0 再加 quantity所以不需要先判断“购物车里有没有这个商品”。加购的同时刷新过期时间用户只要还在活跃购物车就一直延续保存周期。注意这里add只是对 Redis 的两个命令做了封装没有把商品信息写进缓存。实际项目里商品服务会先校验skuId是否存在、库存是否充足校验通过后再调购物车服务写 Redis。为了演示方便我在代码里省略了商品校验环节但在生产代码中这步不可缺少。4.3 修改数量、删除商品、查询的完整实现修改数量最常见的两种场景一种是“用户手动把数量从 2 改成 5”属于覆盖式赋值另一种是“用户点加减按钮每次加一或减一”属于增量式修改。前者用hset后者用hincrby。为了安全起见修改后的数量不能小于等于 0否则直接视为删除该商品def update_quantity(self, user_id, sku_id, new_quantity): if new_quantity 0: return self.remove(user_id, sku_id) key self._cart_key(user_id) self.client.hset(key, sku_id, new_quantity) self.client.expire(key, self.TTL_SECONDS) return new_quantity def increase_quantity(self, user_id, sku_id, delta1): key self._cart_key(user_id) result self.client.hincrby(key, sku_id, delta) if int(result) 0: self.remove(user_id, sku_id) return 0 self.client.expire(key, self.TTL_SECONDS) return int(result) def remove(self, user_id, sku_id): key self._cart_key(user_id) self.client.hdel(key, sku_id) return True def get_cart(self, user_id): key self._cart_key(user_id) return self.client.hgetall(key) # 返回 {sku_id: quantity}hgetall返回的是一个字典比如{sku1001: 3, sku1002: 1}。注意即使我们在连接时开启了decode_responsesHash 的 value 在 Redis 里仍以整数形式存储时返回时可能是字符串所以计算金额时记得用int()做一次转换。4.4 购物车和商品信息的合并展示真实场景中购物车接口不能只返回 sku 和数量前端需要的是“商品标题、价格、图片、小计、合计”。所以拿到 Redis 里的购物车数据后要拿着 skuId 列表回源商品服务。这里用模拟数据演示合并逻辑def build_cart_view(cart_service, user_id): cart_data cart_service.get_cart(user_id) if not cart_data: return {items: [], total_price: 0.00} sku_ids list(cart_data.keys()) goods_table load_goods_info(sku_ids) # 真实项目中改为查MySQL或商品微服务 items [] total_price 0.0 for sku_id, quantity in cart_data.items(): goods goods_table.get(sku_id, {}) title goods.get(title, 未知商品) price goods.get(price, 0.0) qty int(quantity) subtotal price * qty total_price subtotal items.append({ sku_id: sku_id, title: title, price: price, quantity: qty, subtotal: round(subtotal, 2), }) return {items: items, total_price: round(total_price, 2)} def load_goods_info(sku_ids): return { sku1001: {title: 纯棉短袖, price: 79.00}, sku1002: {title: 休闲帆布鞋, price: 159.00}, }这段代码里最核心的设计决策是Redis 只承担购物车状态的存取商品信息始终保持实时查询。如果商品服务不可用或响应慢最多是购物车页面的商品信息渲染不出来车辆件数数据仍然完好不会因为商品服务抖动导致整个购物车数据错乱。4.5 完整跑通的体验链路把上面几个方法组合起来就能还原一个完整的用户操作链路client redis.Redis(host127.0.0.1, port6379, passwordyourpassword, decode_responsesTrue) cart CartService(client) # 用户10086加购3件短袖、1双帆布鞋 cart.add(user_10086, sku1001, 3) cart.add(user_10086, sku1002, 1) # 短袖实际只要2件调整数量 cart.update_quantity(user_10086, sku1001, 2) # 查看购物车 print(cart.get_cart(user_10086)) # 输出: {sku1001: 2, sku1002: 1} # 用户心仪那双鞋卖完了直接删除 cart.remove(user_10086, sku1002) print(build_cart_view(cart, user_10086))每一步操作之后都可以用redis-cli里的HGETALL cart:user_10086对照验证看到的数据和程序里读到的完全一致理解会直观很多。到这里一个“简易购物车系统”的核心部分已经完整跑通了。5. 上线前最容易踩的四个坑5.1 序列化的坑别被 \x00 之类的乱码吓到用 redis-py 时很多人会在连接后看到类似b\x00\x05sku\x06\x00\x05的输出第一反应是“数据是不是写坏了”。其实这只是 Redis 在客户端和服务端之间传输数据时默认的二进制安全特性如果连接没有开启decode_responsesTrue你读到的就是原始字节流。更隐蔽的一个坑出现在用连接池与 Java 客户端时。Spring Data Redis 里如果不显式配置StringRedisSerializer可能会默认采用 JDK 序列化存进去的 key 前面会多出奇怪的前缀肉眼看着像乱码程序里互相匹配不上。所以无论用什么语言接入 Redis第一件事就是和运维或同组的同学对齐“序列化策略”统一采用 String 序列化读取避免各自为政。5.2 缓存穿透请求打到不存在的商品上购物车合并商品信息时如果某个 skuId 在商品服务里并不存在每次请求都会穿透到数据库查这个并不存在的商品。这样的请求量一大数据库同样会被拖垮。业界通用的方案有两个第一个是缓存空值——查询不到商品时在缓存里写一个空对象并设置较短的 TTL比如 3 分钟后续同样的请求短时间内直接命中空值不再穿透第二个是布隆过滤器把所有合法的 skuId 提前布成一个过滤网请求进来先判断是否存在不存在就直接返回默认数据完全不查询数据库。对于初学者我建议先采用第一种方案因为在业务早期“合法 skuId 集合”还没有稳定下来维护布隆过滤器的成本略高。等数据量真正起来后再考虑引入布隆过滤器或布谷鸟过滤器。5.3 连接管理别每次请求都创建新连接Redis 基于 TCP 通信每条连接都需要握手和释放。如果每个 HTTP 请求进来都Redis()一次底层相当于不停地建连、断连在高并发下连接数会迅速堆积反而拖垮 Redis 服务。更糟糕的情况是连接耗尽之后所有请求全部超时接口瞬间雪崩。正确做法是复用同一个客户端实例或者使用连接池。redis-py 提供了现成的连接池pool redis.ConnectionPool( host127.0.0.1, port6379, passwordyourpassword, max_connections20, decode_responsesTrue, ) r redis.Redis(connection_poolpool)加购、查询等操作都从连接池里取连接用完归还既省去了频繁建连开销也天然限制了 redis 连接的总量。真实项目里不要在一个函数内部反复新建Redis()实例这是一个非常典型的性能陷阱。5.4 购物车一致性问题价格变了怎么办前面一直强调购物车只存 skuId 和数量商品信息实时查询。这样做的主要目的就是规避价格一致性问题。但还有一种情况没有办法完全绕开用户在提交订单时商品价格在“查询购物车”和“提交订单”之间可能发生了变化比如刚好遇到 0 点活动涨价/降价。此时购物车页面显示的价格和订单确认页显示的价格就会不同用户体验会非常奇怪。实际项目里的兜底做法是用户提交订单那一刻二次向商品服务校验价格以最新价格为准生成订单并把两端的差异如果有明确提示给用户而不是直接按购物车里的旧快照下单。这层“校验-重算”逻辑放在订单系统里不属于 Redis 购物车组件本身但设计购物车接口时就要预留好这个回源路径避免订单系统面对一个无法核对价格的购物车数据。还有一个小技巧是即使选择了实时查商品的架构也可以在 Redis 里为 skuId 到商品信息的映射设置一个较短的缓存比如 3 分钟把商品服务的压力挡掉一部分缓存失效后重新回源数据顶多滞后 3 分钟购物车页面对此并不敏感。写在最后其实把一个购物车系统用 Redis 写出来只是 Redis 万千场景中的冰山一角。我在这类小项目中体会最深的一点是工具不难学难的是把业务需求翻译成数据结构。一旦你习惯了“一个用户的一切状态都可以收敛成一个 key”再看 Hash 存购物车、ZSet 存排行榜、List 存消息队列这些套路就会自然地举一反三。如果你手里正好有一个练习项目想改造我建议先别急着直接连 Redis而是把现有关系的查询频率和写频率列个清单找到最痛的那一个接口只把这个接口的缓存链路做出来。这样既不会背上“为了用 Redis 而用 Redis”的包袱也能在最少改动里真正感受到缓存带来的性能变化。购物车只是入口顺着这个思路往下走分布式锁、限流、延迟队列这些进阶应用都会一步步展开加油。
返回列表