从Redis缓存到MySQL索引:拆解高并发外卖系统后端核心设计

📅 2026/8/6 4:19:51
从Redis缓存到MySQL索引:拆解高并发外卖系统后端核心设计
1. 项目概述从“苍穹外卖”面试题看后端工程师的核心能力栈最近在帮团队筛选候选人也和一些朋友交流面试准备发现“苍穹外卖”这个项目在面试中出现的频率相当高。它不像一个简单的CRUD项目更像是一个微缩版的、五脏俱全的真实线上系统涵盖了从用户下单、商家接单、骑手配送、支付结算到数据统计的全链路。面试官通过它考察的绝不仅仅是你会不会写接口更深层次的是你对一个完整业务系统的理解深度、技术选型的思考以及应对高并发场景的实战能力。我自己也复盘过围绕“苍穹外卖”的面试题核心其实就几个关键词Redis、MySQL、缓存穿透、淘汰机制、IO多路复用。这几个词串起来几乎就是后端工程师从数据存储、性能优化到系统稳定性的核心知识图谱。今天我就结合自己这些年踩过的坑和带人的经验把这些高频考点掰开揉碎了讲一讲希望能帮你构建一个清晰、有深度的知识体系而不仅仅是背几个面试题答案。2. 核心需求解析为什么面试总爱问这些面试官抛出“苍穹外卖”这个场景本质上是在模拟一个典型的、有挑战性的互联网应用。我们得先理解这个场景下的核心业务压力和技术挑战才能明白为什么那些技术点是必考的。2.1 业务场景下的技术挑战想象一下“苍穹外卖”的日常午高峰和晚高峰成千上万的用户同时浏览商家菜单、下单、查看订单状态。这个过程中系统面临几个核心压力点极高的读多写少比例用户浏览菜单、查看商家评分、查询订单状态的频率远高于下单支付的频率。这意味着缓存是提升性能、降低数据库压力的第一道也是最重要的一道防线。数据的强一致性与最终一致性并存订单状态如“已支付”、“配送中”、“已完成”需要强一致性用户和商家需要立刻看到准确状态。而商家评分、销量统计这类数据则可以接受短时间内的最终一致性适合用缓存异步更新策略。瞬时高并发与热点数据热门商家的菜单、爆款菜品在高峰时段会被海量请求同时访问。如果所有请求都打到数据库数据库连接池瞬间就会被撑爆这就是典型的缓存穿透和缓存击穿问题。系统资源的有效管理与回收缓存不能无限增长。哪些数据应该被优先保留如热门商家信息哪些数据可以淘汰如很久没人浏览的冷门菜品这就需要合理的缓存淘汰机制。海量连接的高效处理外卖APP通常使用长连接如WebSocket来推送订单状态变更给骑手和商家。服务器需要同时维持数十万甚至上百万的连接并能在任何连接有数据到达时快速响应。用传统的“一个线程处理一个连接”的阻塞IO模型服务器资源根本不够用这时就必须用到IO多路复用技术。理解了这些业务挑战你就会发现面试官问的每一个技术点都不是孤立的而是为了解决这些具体问题而存在的。你的回答如果能体现出这种“问题驱动技术选型”的思路分数会高很多。2.2 面试考察的能力维度基于上述挑战面试官通过相关问题主要考察你以下几个维度基础扎实度对Redis、MySQL等核心组件的基本原理是否真正理解而不是只会用API。场景化设计能力能否根据“外卖”这个具体业务场景设计出合理的缓存策略、数据库表结构、并发控制方案。问题排查与解决能力当系统出现性能瓶颈如接口超时或数据错误时你的排查思路是什么能否联想到缓存、数据库、网络IO等各个层面。技术深度与广度是否了解这些技术背后的机制如Redis的线程模型、MySQL的索引原理、操作系统的IO模型并能将它们关联起来。接下来我们就针对这几个核心关键词进行深度拆解。3. Redis深度剖析不止是缓存在“苍穹外卖”里Redis的角色绝对是C位。它不仅仅是缓存更是高性能的数据结构服务器和消息队列。3.1 缓存穿透、击穿、雪崩的区分与实战解决方案这是Redis面试的“老三样”但很多人直到面试时还分不清。我们结合外卖场景来彻底搞懂。缓存穿透查询一个根本不存在的数据。比如请求一个不存在的order_id来查订单详情。缓存没有数据库也没有。大量这样的恶意请求会直接穿透缓存压垮数据库。解决方案1缓存空对象。当从数据库查不到时在Redis里也缓存一个空值如SET order:99999 “”并设置一个较短的过期时间如30秒。下次同样的请求就直接在缓存层返回空了。注意需要防范大量不同的不存在的Key打满缓存可以配合下面方案2。解决方案2布隆过滤器。在查询缓存前先用布隆过滤器判断Key是否存在。如果布隆过滤器说“不存在”那这个Key一定不存在直接返回无需查询缓存和数据库。布隆过滤器说“存在”则再去缓存查询。这是一种空间效率极高的概率型数据结构非常适合这种场景。在外卖系统中可以将所有有效的user_id、shop_id、order_id预热到布隆过滤器中。缓存击穿某个热点Key过期瞬间大量请求同时涌向数据库。比如一个销量第一的“招牌黄焖鸡”的菜品详情Keydish:888在高峰时段过期了瞬间所有用户请求都去查数据库。解决方案1互斥锁。当发现缓存失效时不是所有线程都去查数据库而是只有一个线程通过Redis的SETNX命令实现分布式锁去查库并回写缓存其他线程等待锁释放后重新读取缓存。这是最经典的方案。解决方案2逻辑过期。不给缓存数据设置物理过期时间而是将过期时间作为一个字段存储在Value中。当发现数据逻辑过期时同样使用互斥锁由一个线程去异步更新缓存其他线程直接返回旧的、逻辑上已过期的数据。这种方式可以避免缓存失效瞬间的卡顿用户体验更好。对于菜品详情这种允许短暂不一致的数据很适用。缓存雪崩大量Key在同一时间点或时间段内过期导致所有请求都打到数据库。比如系统初始化时批量加载的商家信息都设置了相同的1小时过期时间1小时后集体失效。解决方案差异化过期时间。这是治本之策。在设置缓存过期时间时使用一个基础时间加上一个随机偏移量。例如TTL 3600 Random(-300, 300)这样Key的过期时间就均匀分布在1小时前后5分钟内避免了集体失效。实操心得在实际项目中我们通常会组合使用这些方案。例如对于核心的、访问量大的数据如热门商家信息采用“逻辑过期互斥锁”防击穿对于可能不存在的查询如根据手机号查用户使用“布隆过滤器”防穿透对于所有缓存Key强制使用随机过期时间防雪崩。把这些策略写成公司内部的缓存组件规范能避免很多线上问题。3.2 Redis淘汰策略与内存管理Redis内存满了怎么办这是面试高频题。Redis提供了8种淘汰策略由配置项maxmemory-policy控制策略含义适用场景noeviction不淘汰新写入操作报错。对数据一致性要求极高宁愿报错也不能丢数据的场景。生产环境慎用allkeys-lru从所有Key中淘汰最近最少使用的。最常用。适用于缓存场景希望保留热点数据。volatile-lru从设置了过期时间的Key中淘汰最近最少使用的。缓存数据有明确的生命周期且希望保留热点数据。allkeys-random从所有Key中随机淘汰。所有Key访问概率差不多无明确热点。volatile-random从设置了过期时间的Key中随机淘汰。有过期时间的Key访问概率差不多。volatile-ttl从设置了过期时间的Key中淘汰剩余生存时间最短的。希望尽快清理掉即将过期的数据为新数据腾空间。allkeys-lfu从所有Key中淘汰最不经常使用的。Redis 4.0。能更好地区分高频和低频访问比LRU更精准。volatile-lfu从设置了过期时间的Key中淘汰最不经常使用的。Redis 4.0。同上但只针对有过期时间的Key。如何选择对于“苍穹外卖”这类缓存系统allkeys-lru或allkeys-lfu通常是首选。因为我们的目标是尽可能让热点数据热门商家、热销菜品留在内存中。如果你能明确区分“缓存数据”可丢和“持久数据”不可丢可以将持久数据不设过期时间然后使用volatile-lru策略这样只会淘汰缓存数据。内存优化实战技巧监控与预警使用info memory命令监控used_memory和maxmemory设置水位线告警如80%提前扩容或分析大Key。警惕Big Key一个Key对应的Value过大如一个存储了10万条用户ID的Set会导致操作阻塞、网络流量暴增、内存不均。对于外卖系统不要用一个Key存储全城所有骑手位置而应按区域拆分。善用数据结构比如存储用户签到用String存一个月的签到记录需要30个Key而用BitMap只需要1个Key极大节省内存。存储用户点赞关系用Set可能很大如果只需要判断是否存在可以考虑用BloomFilter替代。3.3 Redis持久化RDB与AOF的抉择Redis是内存数据库数据如何不丢这就涉及到持久化。两种主流方式RDB和AOF。RDB在指定时间间隔生成数据集的时间点快照。文件紧凑恢复速度快。但会丢失最后一次快照后的所有数据。AOF记录每个写操作命令以日志形式追加。数据安全性高最多丢失1秒数据appendfsync everysec配置下。但文件体积大恢复速度慢。生产环境常用策略两者结合使用。使用AOF作为主持久化方式保证数据安全。配置为appendfsync everysec在性能和数据安全间取得平衡。定期如每天手动执行BGSAVE命令创建RDB快照用于历史备份、灾难恢复和数据迁移因为RDB文件更小、恢复更快。定期如每周对AOF文件进行重写BGREWRITEAOF压缩文件体积。在外卖系统中订单状态、支付信息等核心数据不容有失必须依赖AOF的持久化能力。而商家菜单等相对静态的数据即使有少量丢失也可以通过后台系统快速重建。4. MySQL实战支撑订单生命周期的数据库设计Redis再快数据的“单点真理”最终还是要落在MySQL上。外卖系统的数据库设计核心在于如何高效、准确地管理订单的生命周期。4.1 订单表的核心设计思路与索引优化订单表是核心中的核心。设计时需要考虑几个关键点分库分表订单量巨大必须考虑水平拆分。常见的分片键是user_id按用户哈希或order_id包含时间戳的雪花ID。按user_id分方便查询用户历史订单。按order_id分数据分布更均匀。实操中我们常采用order_id分片同时为user_id创建全局二级索引如通过ES或另一张索引表来支持用户维度的查询。字段设计order_id主键使用分布式ID生成器雪花算法。user_id,shop_id外键建立索引。status订单状态1待支付、2已支付、3商家接单、4骑手取餐、5配送中、6已完成、7已取消。这是最频繁的查询和更新字段之一。amount订单金额。create_time,update_time创建和更新时间。address_id配送地址。rider_id骑手ID。冗余字段为了减少关联查询通常会冗余存储user_nameshop_nameshop_address等。用空间换时间在互联网高并发场景下是常见做法。索引优化主键索引order_id 聚簇索引。联合索引(user_id, create_time) DESC。这是查询“我的订单列表”最常用的SQLSELECT * FROM orders WHERE user_id ? ORDER BY create_time DESC LIMIT 0, 20。这个索引能完美覆盖查询和排序。单列索引shop_idstatusrider_id。用于商家后台查单、按状态筛选订单、骑手查单等场景。避免无效索引像status这种区分度很低的字段可能90%的订单都是“已完成”单独建索引效果可能很差。但如果和create_time组成联合索引(status, create_time)用于查询“今天未完成的订单”效果就会很好。这就是索引左前缀原则的应用。踩坑记录曾经有个慢查询是商家后台的“订单管理”页面查询很慢。排查发现查询条件是shop_id ? AND status IN (3,4,5) AND create_time BETWEEN ? AND ?但索引只建了(shop_id)。当商家订单量达到百万级时这个查询需要回表数十万次。后来我们建立了(shop_id, status, create_time)的联合索引查询速度从数秒降到几十毫秒。核心原则索引的设计必须贴合最核心的查询SQL。4.2 事务与并发控制确保订单状态正确流转订单状态从“待支付”到“已完成”涉及多次更新必须保证原子性和一致性。这里最经典的并发问题就是“超卖”和“状态机错乱”。利用数据库事务任何涉及订单核心状态变更和库存扣减的操作都必须放在一个数据库事务中。例如用户支付成功的回调接口中要执行“更新订单状态为已支付”和“扣减菜品库存”两个操作必须原子化。START TRANSACTION; UPDATE orders SET status 2 WHERE order_id ? AND status 1; -- 乐观锁确保状态是从1变为2 UPDATE dish SET stock stock - ? WHERE dish_id ? AND stock ?; -- 防止超卖 COMMIT;如果更新行数为0说明订单状态不对或库存不足需要回滚并给用户明确提示。使用乐观锁如上例所示在UPDATE语句的WHERE条件中加入状态判断这就是一种乐观锁的实现。它比悲观锁SELECT ... FOR UPDATE性能更好在高并发场景下更推荐。状态机设计订单状态必须有清晰、严谨的流转规则。例如“已取消”的订单不能再变为“配送中”。这需要在业务代码层做严格校验。可以定义一个OrderStatusEnum枚举并维护一个状态转移矩阵Map当前状态 Set下一合法状态在变更状态前进行校验。4.3 读写分离与分库分表实践当单表数据超过千万或QPS超过单库承受能力时就必须考虑拆分。读写分离这是第一步。利用MySQL主从复制将写操作下单、更新状态指向主库将大量的读操作查订单、查商家指向多个从库。通过中间件如ShardingSphere-Proxy、MyCat或客户端框架如ShardingSphere-JDBC可以透明地实现。分库分表水平分表如前所述按order_id或user_id的哈希值将订单表拆分到多个物理表如order_00到order_15。垂直分库将不同业务域的表拆分到不同的数据库实例。例如将用户、订单相关的表放在“交易库”将商家、菜品相关的表放在“商品库”将骑手、轨迹相关的表放在“运力库”。这样可以降低单库压力也方便不同团队维护。实施难点与解决方案分布式事务一个下单操作可能涉及“交易库”写订单、“商品库”扣库存。这就需要分布式事务解决方案如Seata的AT模式、或基于消息队列的最终一致性方案更常用。例如扣减库存成功后发送一条MQ消息订单服务监听消息再创建订单。全局唯一ID分表后数据库自增ID不可用必须使用分布式ID生成器如雪花算法。跨分片查询例如运营想查全平台某一天的订单总额。解决方案是1建立专门的OLAP数仓定时同步数据2使用Elasticsearch等搜索引擎建立二级索引进行复杂查询。5. 高并发基石深入理解IO多路复用当你的外卖APP有百万用户在线服务器是如何同时处理这么多网络连接的答案就是IO多路复用。这是理解Redis、Nginx、Netty这些高性能中间件为何如此之快的钥匙。5.1 从BIO到NIO演进之路BIO同步阻塞IO。为每个连接创建一个线程。连接少时没问题但连接数上万时线程上下文切换的开销巨大内存耗尽。这就是经典的“C10K问题”。NIO同步非阻塞IO。核心是Selector选择器。一个线程可以管理多个连接Channel。线程不断轮询Selector看哪些Channel上有事件连接、读、写就绪然后只处理这些就绪的事件。这样一个线程就能处理大量连接。外卖场景联想你的服务器就是一个餐厅前台Selector有很多外卖骑手Channel在等待。传统方式BIO是每个骑手配一个服务员线程。现在只有一个前台骑手来了登记一下要做什么注册事件然后就去旁边等着。前台不断看大屏幕轮询哪个骑手的餐好了事件就绪就叫他来取。效率极大提升。5.2 Reactor模式高性能网络编程的骨架IO多路复用是机制Reactor模式是使用这一机制的设计模式。它定义了事件分发和处理的架构。单Reactor单线程Redis 6.0之前的工作模式。所有事件连接、读写都由一个线程处理。简单高效但无法利用多核且一个慢操作会阻塞所有客户端。单Reactor多线程一个线程主Reactor只负责接收新连接然后将建立好的连接分发给多个工作线程SubReactor去处理IO读写和业务逻辑。这是更常见的模式。主从Reactor多线程Netty、Nginx采用。有主从两组Reactor。主Reactor负责接收连接然后分发给多个从Reactor。每个从Reactor在一个独立线程中运行负责管理一批连接的IO事件。业务逻辑可能再交给额外的线程池处理。这种模式将连接建立和IO处理进一步分离扩展性最强。为什么Redis之前用单线程因为Redis的操作都是内存操作速度极快瓶颈在网络IO和内存访问而不是CPU。单线程避免了多线程的锁竞争和上下文切换开销反而使实现简单、性能稳定。Redis 6.0引入多线程主要是为了处理网络IO的读写这部分仍然是多路复用而命令执行依然是单线程保证了原子性。5.3 在外卖系统中的应用Web服务器你的Spring Boot应用底层通过Tomcat或Netty处理HTTP请求它们都使用了NIO和Reactor模式来支持高并发连接。消息推送向骑手和商家实时推送订单状态变更通常使用WebSocket或长轮询。服务端维持海量长连接正是IO多路复用的用武之地。Netty是构建此类服务的首选框架。RPC框架微服务间的调用如订单服务调用支付服务底层的网络通信库如Dubbo使用的Netty gRPC也基于此模型。理解IO多路复用能让你在面试中解释清楚“为什么我的服务能抗住高并发”而不仅仅是说“我们用了Redis和MQ”。6. 系统设计实战构建一个抗压的外卖订单系统让我们把上面的知识点串起来设计一个简化的“下单-支付-推送”流程看看如何应用这些技术。6.1 下单流程的缓存与数据库协同查询菜品库存读多请求到达网关先查询Redis缓存GET dish_stock:123。如果缓存命中直接返回。如果缓存未命中穿透查询数据库并将结果写入Redis设置随机过期时间如5分钟随机数。对于不存在的菜品ID缓存空值短时间。防击穿对于热门菜品在缓存未命中时使用Redis分布式锁只让一个请求去查库回种缓存。创建订单写生成分布式订单ID。在一个数据库事务中插入订单主表、插入订单明细表、扣减菜品库存数据库行锁或乐观锁保证。事务成功后异步执行以下操作将订单信息放入Redis缓存Key如order:${orderId}设置过期时间如30分钟方便用户快速查询。更新Redis中该商家的“今日订单数”等统计信息使用INCR命令。清除或更新相关菜品的库存缓存DEL dish_stock:123保证下次读取时获取最新数据。6.2 订单状态推送与异步处理支付回调支付平台回调通知支付成功。更新订单状态为“已支付”。这里要防重先查Redis或数据库判断该订单是否已处理过或使用数据库乐观锁UPDATE ... WHERE status待支付。状态更新后向消息队列如RocketMQ/Kafka发送一条事件消息主题为ORDER_PAID消息体包含orderId。消息驱动与推送商家接单系统订阅ORDER_PAID消息通知商家有新订单。商家端通过WebSocket长连接基于Netty实现IO多路复用管理连接接收实时通知。骑手调度系统也订阅ORDER_PAID消息进行智能派单或抢单。用户端推送用户APP通过长连接等待订单状态更新。当订单服务更新状态后会通过WebSocket网关向指定用户连接推送消息。这样设计的好处解耦支付回调服务只负责更新状态和发消息不关心谁来处理。新增一个需要感知支付成功的服务如发优惠券只需订阅消息即可。削峰填谷高峰期的支付成功消息可以堆积在MQ中下游服务按能力消费避免被冲垮。最终一致性通过消息队列保证了跨服务的数据最终一致。7. 面试复盘与深度问题准备最后我们来模拟一下面试官可能追问的深度问题并给出回答思路。7.1 缓存与数据库双写一致性问题问题更新数据库后是先更新缓存还是先删除缓存如果删除缓存失败怎么办回答思路结合外卖场景 这是一个经典难题没有银弹只有权衡。常用策略是“Cache Aside Pattern”的变种。首选策略先更新数据库再删除缓存。原因先删缓存再更新数据库在并发下更容易导致长时间的数据不一致另一个线程在缓存删除后、数据库更新前读到了旧值并回种了缓存。外卖场景更新商家地址。先更新数据库再删除shop:info:${id}缓存。即使删除缓存失败顶多是下次读到旧地址短暂不一致可以通过缓存过期或后续重试来修正。保证最终一致性删除缓存可能失败。可以重试机制将删除失败的Key放入消息队列异步重试删除。设置合理的过期时间给缓存数据设置一个不太长的过期时间如10分钟作为兜底确保最终一致。监听数据库Binlog使用Canal等工具监听MySQL的Binlog当感知到数据变更时自动删除或更新对应的缓存。这是一劳永逸的方案但对架构复杂度要求高。7.2 如何设计一个分布式锁问题除了用Redis的SETNX分布式锁还要考虑什么回答思路SETNX只是基础生产环境必须考虑完备。原子性加锁与设置过期时间必须用一条命令完成防止设置过期时间前客户端崩溃导致死锁。Redis 2.8后可以用SET lock_key unique_value NX PX 30000。唯一标识与防误删锁的值必须是一个唯一标识如UUID线程ID。解锁时要用Lua脚本先判断当前锁的值是否是自己设置的再删除。防止误删其他客户端的锁。锁续期如果业务执行时间可能超过锁过期时间需要有一个“看门狗”线程定时续期。Redisson客户端库实现了这个逻辑。高可用单点Redis宕机会导致锁失效。可以考虑RedLock算法多个独立Redis实例但争议较大。更主流的是用ZooKeeper或etcd的临时有序节点来实现但性能不如Redis。选择取决于场景要性能选Redis配合红锁或集群要强一致选ZooKeeper。7.3 如果Redis集群挂了如何降级问题缓存全盘失效流量直接打到数据库如何保护系统回答思路这是关于系统弹性和高可用的思考。事前预防集群与分片使用Redis Cluster或Codis避免单点故障。多级缓存本地缓存如Caffeine Redis缓存。即使Redis挂掉本地缓存还能扛一部分热点请求。缓存预热在系统启动或低峰期提前加载热点数据到缓存。事中熔断与降级熔断器使用Hystrix或Sentinel当访问Redis的失败率达到阈值快速失败直接走降级逻辑如查数据库但限流避免线程池被拖垮。降级逻辑缓存失效时业务上能否接受返回默认值、简化数据或排队提示例如外卖菜品详情页如果缓存挂了可以降级为只返回基础信息名称、价格不返回复杂的描述和图片。事后快速恢复备份与恢复有最近的RDB/AOF备份可以快速恢复数据。流量限制缓存恢复期间通过网关对非核心接口进行限流优先保障核心下单、支付链路。准备“苍穹外卖”面试题本质上是在梳理一个后端工程师面对高并发、大数据量、分布式环境时的核心知识体系和解决方案。它要求你不仅知道工具怎么用更要理解工具背后的原理以及如何在具体的业务场景中做出合理的技术选型和架构折衷。希望这篇长文能帮你把散落的知识点串联成网在面试中展现出你系统性的思考能力。记住最好的准备就是理解原理并结合实际场景去思考和表达。