苍穹外卖项目技术架构与面试高频问题解析

📅 发布时间:2026/7/6 11:08:41 👁️ 浏览次数:
苍穹外卖项目技术架构与面试高频问题解析
1. 项目架构全景从单体到微服务的演进之路很多同学在面试中被问到“苍穹外卖”这类项目时第一反应就是背八股文说用了什么技术栈。但面试官真正想听的是你对这个项目架构演进的思考。我当年做这个项目时一开始也不是上来就搞一堆复杂中间件而是从最朴素的单体架构开始的。最开始项目就是个简单的Spring Boot应用所有模块——用户、菜品、订单、员工管理——都打包在一个JAR包里扔到内嵌的Tomcat里就跑起来了。前端呢就是一些静态页面直接放在Nginx里。这个阶段的核心矛盾是“快速上线验证业务”所以技术选型怎么简单怎么来。但很快用户量上来第一个瓶颈就出现了所有请求都打到同一个Tomcat上高峰期直接卡死。这时候架构的第一次演进就发生了前后端分离与反向代理。我们把前端静态资源HTML、CSS、JS彻底从后端工程里剥离出来独立部署在Nginx服务器上。后端API则单独部署。你可能会问用户访问前端页面页面里的JS再去请求后端API这不就跨域了吗没错所以Nginx在这里扮演了关键角色——反向代理。我们在Nginx里配置一条规则把所有以/api/开头的请求转发到后端的Tomcat服务器。对浏览器来说它始终只和Nginx对话根本不知道后端服务器的存在。这带来的好处太大了前端可以独立开发部署后端API可以灵活扩容而且Nginx本身性能极高能扛住大量静态资源请求。随着业务量进一步增长单台后端服务器又成了瓶颈。这时就引入了负载均衡。我们买了两台配置一样的服务器都部署上后端JAR包。然后在Nginx配置里用upstream指令定义了一个服务器组把这两台机器的地址都列进去。Nginx默认使用轮询策略把请求均匀分给这两台机器。这就好比一个餐厅原来只有一个厨师现在有了两个接待能力直接翻倍。这里有个实战细节为了确保会话Session不丢失我们采用了Redis来集中存储Session这样无论用户请求被分配到哪台后端服务器都能拿到登录状态。数据库层面单点故障和读写压力是下一个要解决的问题。我们采用了MySQL主从复制。主库Master负责处理所有的写操作增、删、改比如用户下单、管理员修改菜品信息。从库Slave通过MySQL的binlog日志实时同步主库的数据专门负责处理读操作查比如用户浏览菜品、查看订单历史。这样读写分离主库的压力就小了很多。在代码里我们用了Sharding-JDBC这个轻量级框架来透明地实现读写分离。配置好主、从数据源写操作会自动路由到主库读操作会自动路由到从库对业务代码几乎是零侵入。这个方案上线后在促销期间数据库的CPU负载下降了近40%。最后为了应对高并发查询我们引入了Redis缓存集群。像菜品分类、店铺营业状态这种变化不频繁但访问量巨大的数据非常适合放进缓存。我们采用了一主两从三哨兵的模式。主节点负责写从节点负责读哨兵节点负责监控主节点健康状态一旦主节点宕机能自动选举新的主节点实现高可用。我印象很深的是第一次把菜品列表接口加上缓存后平均响应时间从200毫秒直接降到了20毫秒以内效果立竿见影。所以你看一个项目的架构不是一蹴而就的而是随着业务发展一步步“生长”出来的。从单体到分离从单机到集群每一步都是为了解决当前阶段最突出的矛盾。在面试中你能把这个演进过程和自己做的技术决策讲清楚远比罗列一堆技术名词要加分得多。2. 核心模块深度拆解与高频面试题2.1 Nginx不只是反向代理更是性能守卫在苍穹外卖里Nginx是我们的第一道大门。很多同学只知道配个proxy_pass就完事了但里面的门道可不少。首先反向代理的配置我们通常不会直接写IP而是用upstream定义后端服务器组这样管理和扩容都方便。upstream backend_servers { server 192.168.1.101:8080 weight2; # weight表示权重这台机器处理2份请求 server 192.168.1.102:8080 weight1; # 这台处理1份性能好的机器可以多分担 keepalive 32; # 保持长连接数量减少TCP握手开销 } server { listen 80; server_name sky-takeout.com; location /api/ { proxy_pass http://backend_servers; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 把用户真实IP传给后端很重要 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_connect_timeout 3s; # 连接后端超时时间 proxy_read_timeout 10s; # 读取后端响应超时时间 } # 静态资源服务开启gzip压缩和缓存 location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ { root /usr/share/nginx/html; expires 7d; # 客户端缓存7天 gzip on; gzip_types text/css application/javascript; } }面试高频问题1Nginx负载均衡策略有哪些你们用的哪种为什么除了默认的轮询round-robin常用的还有weight权重给性能好的服务器更高权重让它处理更多请求。我们给新采购的高配服务器权重设为3老服务器设为1。ip_hash根据客户端IP计算哈希固定分配到某台后端。这能解决Session问题但我们用了Redis存Session所以没选这个。least_conn把新请求发给当前连接数最少的服务器。这在后端服务器处理能力不均时比较有用。fair第三方按后端服务器的响应时间分配响应快的多接活。我们主要用加权轮询。原因很简单我们的后端服务器配置不完全一样新机器性能强理应多承担流量。ip_hash虽然能保持会话但可能导致负载不均而且如果某台服务器宕机上面绑定会话的用户就会受影响。least_conn和fair更智能但需要额外的模块支持我们当时追求的是稳定和简单可控。面试高频问题2正向代理和反向代理的区别这个问题几乎必问。你可以这样通俗地解释正向代理替客户端办事。比如你在公司网络不能直接访问某网站你通过公司的一台代理服务器去访问。对于目标网站来说请求来自代理服务器它不知道你的存在。代理对象是客户端。反向代理替服务器办事。比如用户访问sky-takeout.com实际上请求被Nginx接收Nginx再悄悄转发给后端的Tomcat服务器。对于用户来说他以为Nginx就是最终服务器。代理对象是服务端。我们项目中用的Nginx就是典型的反向代理它隐藏了后端服务器的真实IP提供了负载均衡和安全屏障。2.2 Redis缓存设计与穿透、雪崩、击穿应对实战用Redis做缓存绝不是set、get那么简单。在苍穹外卖里我们主要缓存了三类数据全局性低频变更数据菜品分类、店铺营业状态。这类数据一旦放入除非管理员后台修改否则不会变缓存时间可以设得很长比如12小时。热点数据今日推荐菜品、销量排行榜。这类数据访问频率极高我们设置一个较短的过期时间如5分钟并配合定时任务定期更新。会话数据Session用户登录状态。我们采用Spring Session集成Redis将Tomcat的HttpSession外置实现了分布式集群下的会话共享。缓存策略我们采用“旁路缓存”模式先查缓存命中则返回未命中则查数据库写入缓存后再返回。这是最常用的模式。面试高频问题3如何解决缓存穿透、雪崩、击穿这是Redis三座大山必须讲清楚。缓存穿透请求一个数据库中根本不存在的数据比如id-1的商品缓存没有每次都会打到DB可能压垮数据库。我们的解决方案布隆过滤器Bloom Filter。在查询缓存前先用布隆过滤器判断这个key是否存在。布隆过滤器说“不存在”那一定不存在直接返回空。布隆过滤器说“存在”则可能存在有极小的误判率再去查缓存/DB。我们用了Guava库提供的布隆过滤器在系统启动时预热所有有效的菜品ID、用户ID等。补充方案对于查询结果为空的key也缓存一个空值如set key-null 并设置一个很短的过期时间30秒下次同样的请求直接返回空避免穿透。缓存雪崩同一时间大量缓存同时过期所有请求瞬间涌向数据库。我们的解决方案给缓存过期时间加上随机值。比如原本缓存1小时现在改成1小时 随机(0-300秒)。这样就能避免大量key在同一时刻失效。补充方案热点数据永不过期通过后台定时任务异步更新。或者采用Redis集群将数据分散到不同节点降低单个节点宕机的影响。缓存击穿某个热点key过期瞬间大量并发请求同时来查这个key全部打到DB。我们的解决方案互斥锁Mutex Lock。当第一个发现缓存过期的请求到达时它去获取一个分布式锁我们用Redis的SETNX命令实现然后去数据库加载数据并回设缓存。其他并发请求在此期间要么等待锁释放要么直接返回旧的缓存数据如果业务允许。等第一个请求完成后释放锁后续请求就能从缓存中获取到新数据了。// 伪代码示例使用Redis分布式锁解决缓存击穿 public Product getProductById(Long id) { String cacheKey product: id; String productJson redisTemplate.opsForValue().get(cacheKey); if (productJson ! null) { return parseProduct(productJson); } // 缓存未命中尝试获取锁 String lockKey lock:product: id; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (locked) { try { // 双重检查防止其他线程已经更新了缓存 productJson redisTemplate.opsForValue().get(cacheKey); if (productJson ! null) { return parseProduct(productJson); } // 查数据库 Product product productMapper.selectById(id); if (product ! null) { redisTemplate.opsForValue().set(cacheKey, toJson(product), 30, TimeUnit.MINUTES); } else { // 防止穿透缓存空值 redisTemplate.opsForValue().set(cacheKey, , 1, TimeUnit.MINUTES); } return product; } finally { // 释放锁 redisTemplate.delete(lockKey); } } else { // 未获取到锁等待片刻后重试或返回降级数据 Thread.sleep(50); return getProductById(id); // 简单重试生产环境需控制次数 } }面试高频问题4Redis为什么快纯内存操作数据都在内存里读写速度是纳秒级磁盘是毫秒级差了好几个数量级。单线程模型核心网络请求处理避免了多线程上下文切换和竞争的开销。虽然6.0之后引入了多线程处理网络I/O但核心的键值对读写命令处理仍是单线程保证了原子性。高效的数据结构Redis自己实现了一套精炼的数据结构比如跳跃表实现有序集合压缩列表实现小数据量的哈希和列表非常节省内存且高效。I/O多路复用使用epoll这样的机制一个线程可以管理成千上万个连接避免了为每个连接创建线程的开销。2.3 MySQL与Sharding-JDBC读写分离下的数据一致性我们项目里订单表增长非常快单表很快就要破千万。单纯的主从读写分离不够我们还考虑了分库分表但鉴于项目规模和团队经验初期只实现了读写分离。这里重点讲一下Sharding-JDBC的配置和坑。首先在application.yml中配置主从数据源spring: shardingsphere: datasource: names: master,slave0,slave1 # 定义数据源名称 master: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://master-host:3306/sky_takeout?useUnicodetrue username: root password: master-password slave0: ... # 配置从库1 slave1: ... # 配置从库2 masterslave: load-balance-algorithm-type: round_robin # 从库负载均衡策略轮询 name: ms # 主从逻辑数据源名称 master-data-source-name: master slave-data-source-names: slave0,slave1 props: sql: show: true # 显示SQL日志调试用面试高频问题5主从同步有延迟怎么办写后立刻读可能读不到最新数据。这是我们遇到的一个典型问题。用户下单成功后立刻跳转到订单详情页如果这个查询请求被路由到了从库而主从同步有几百毫秒的延迟用户就会看到“订单不存在”体验极差。我们的解决方案强制走主库对于这类“写后立即读”的关键业务我们使用Sharding-JDBC的HintManager强制将查询路由到主库。GetMapping(/order/{orderId}) public OrderDetail getOrderDetail(PathVariable String orderId) { // 尝试从缓存读 OrderDetail detail redisTemplate.opsForValue().get(order: orderId); if (detail ! null) { return detail; } // 缓存没有且是刚创建的订单强制走主库查询 try (HintManager hintManager HintManager.getInstance()) { hintManager.setMasterRouteOnly(); // 设置强制路由到主库 detail orderMapper.selectById(orderId); if (detail ! null) { redisTemplate.opsForValue().set(order: orderId, detail, 5, TimeUnit.MINUTES); } return detail; } }关键数据缓存订单创建成功后除了落库也立刻写入Redis缓存一份设置一个较短的过期时间比如5分钟。后续的读请求优先走缓存避免直接查从库。缓存和数据库的最终一致性通过监听数据库binlog的 Canal 或者 Debezium 来保证但这个方案我们当时没上因为复杂度较高。面试高频问题6如何保证缓存与数据库的双写一致性这是一个经典难题。我们根据数据一致性要求的高低采用了不同策略最终一致性容忍短暂不一致对于菜品分类、用户头像这类信息采用“先更新数据库再删除缓存”的策略。为什么是删缓存而不是更新缓存因为更新缓存可能涉及复杂计算且并发写缓存可能引发数据错乱。删除缓存后下次查询自然会从数据库加载最新值到缓存。虽然存在一个“删除缓存后、下次查询前”的短暂不一致窗口但业务可以接受。强一致性要求极高对于库存、优惠券数量这类资金敏感数据我们采用了“先更新数据库再更新缓存”并且用分布式锁保证这两个操作的原子性。确保在更新过程中不会有其他请求读到脏数据。当然这会牺牲一些性能。2.4 布隆过滤器自己实现 vs. 第三方库在解决缓存穿透时我们提到了布隆过滤器。面试官很喜欢追问细节。面试高频问题7布隆过滤器的原理是什么误判率怎么控制布隆过滤器本质上是一个很长的二进制向量位数组和一系列随机映射函数哈希函数。它的工作流程是添加元素将元素通过K个哈希函数计算得到K个哈希值将位数组中对应的K个位置设为1。检查元素同样用这K个哈希函数计算元素的K个哈希值检查位数组中对应的K个位置是否都为1。如果全是1则可能存在有误判如果有一个为0则一定不存在。误判率取决于位数组大小m、哈希函数个数k和已插入元素数量n。公式很复杂但结论很简单位数组越大误判率越低哈希函数个数需要取一个最优值。我们使用Guava库时可以直接指定预期的元素数量n和可接受的误判率fppfalse positive probability库会自动计算所需的m和k。// 使用Guava布隆过滤器 BloomFilterString bloomFilter BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), 1000000, // 预期插入100万个元素 0.01 // 可接受的误判率1% ); // 预热数据 for (String validId : allValidIds) { bloomFilter.put(validId); } // 查询前判断 if (!bloomFilter.mightContain(queryId)) { return null; // 一定不存在直接返回 } // 可能存在继续后续查询面试高频问题8为什么不用Redis自带的BitMap自己实现我们评估过。自己实现需要确定m和k。实现多个无碰撞的哈希函数。处理布隆过滤器的扩容问题布隆过滤器一旦创建扩容很麻烦。 使用Guava库几行代码就搞定了而且经过充分测试性能稳定。在分布式环境下如果需要共享布隆过滤器我们会把Guava构建好的位数组序列化后存入Redis BitMap兼顾了开发效率和分布式需求。自己造轮子的性价比不高除非有极其特殊的定制需求。3. 业务场景下的技术实战与设计模式3.1 订单超卖与分布式锁外卖业务最核心、最怕出问题的就是订单和库存。超卖问题我估计每个电商项目都会遇到。我们的菜品库存是存在数据库里的在“秒杀”或“爆款促销”时并发扣减库存就是个挑战。最初 naive 的方案UPDATE dish SET stock stock - 1 WHERE id #{dishId} AND stock 0;这条SQL在低并发下没问题但它不是原子的“查询更新”吗在高并发下多个线程可能同时读到stock1然后都执行了stock-1结果库存变成了 -1超卖了。解决方案一数据库悲观锁BEGIN; SELECT * FROM dish WHERE id #{dishId} FOR UPDATE; -- 加行锁 -- 在代码中判断 stock 0 UPDATE dish SET stock stock - 1 WHERE id #{dishId}; COMMIT;用SELECT ... FOR UPDATE锁住这行数据别的线程必须等待。这确实能解决超卖但性能太差FOR UPDATE会让并发请求串行化不适合高并发场景。我们最终采用的方案Redis分布式锁 Lua脚本为什么是Redis因为它的单线程模型和原子操作特性非常适合做分布式锁。抢锁用SET lock_key unique_value NX PX 30000命令。NX表示只有key不存在时才设置PX设置过期时间防止死锁。unique_value一般用UUID确保是当前线程的锁。扣减库存获取锁后我们在Redis中预扣库存。我们把库存也缓存一份在Redis里比如dish_stock:1001用DECR命令原子扣减。如果扣减后库存0说明成功然后异步去更新数据库库存。如果库存不足直接返回失败。关键点整个“判断库存-扣减”的操作必须用Lua脚本保证原子性避免多个命令执行间隙被其他请求打断。-- 扣减库存Lua脚本 local key KEYS[1] -- 库存key如 dish_stock:1001 local change tonumber(ARGV[1]) -- 扣减数量如 -1 local current redis.call(GET, key) if (not current) then return -1 -- 缓存中无库存记录需回源查数据库 end if (tonumber(current) change 0) then redis.call(INCRBY, key, change) return tonumber(current) change -- 返回扣减后的库存 else return -2 -- 库存不足 end释放锁用Lua脚本判断unique_value是否还是自己的是则删除。防止误删其他线程的锁。-- 释放锁Lua脚本 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个方案将库存扣减的压力从数据库转移到了Redis利用Redis的高并发能力扛住了流量峰值。数据库库存只作为最终持久化的兜底通过定时任务或监听binlog来同步Redis的扣减结果实现最终一致。3.2 ThreadLocal在权限与事务上下文中的妙用ThreadLocal是Java中一个看似简单但极易用错的工具。在苍穹外卖里我们主要在两个场景用它场景一用户权限上下文传递用户登录后我们会把他的ID、角色等信息生成一个JWT Token返回给前端。后续请求中前端在Header里带上Token。在拦截器里我们解析Token将用户信息如userId存入ThreadLocal。public class UserContext { private static final ThreadLocalLong USER_ID_HOLDER new ThreadLocal(); public static void setUserId(Long userId) { USER_ID_HOLDER.set(userId); } public static Long getUserId() { return USER_ID_HOLDER.get(); } public static void clear() { USER_ID_HOLDER.remove(); // 关键必须清理 } } // 在拦截器或过滤器中 Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); Claims claims JwtUtils.parseToken(token); // 解析JWT UserContext.setUserId(claims.get(userId, Long.class)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束后务必清除ThreadLocal防止内存泄漏 UserContext.clear(); } }这样在Service层、Mapper层的任何地方都可以通过UserContext.getUserId()无侵入地获取当前用户ID用于自动填充create_user,update_user等字段或者做权限校验。场景二管理数据库事务上下文在一些复杂业务中我们可能需要手动控制事务边界。虽然Spring的Transactional注解很好用但在某些需要根据业务逻辑动态开启/提交/回滚事务的场景我们会用TransactionTemplate并结合ThreadLocal来标记一个“事务上下文”确保在同一个线程内多个数据库操作共享同一个连接和事务。面试高频问题9ThreadLocal为什么会内存泄漏如何避免这是ThreadLocal的经典坑。每个Thread内部都有一个ThreadLocalMap它的key是弱引用的ThreadLocal实例value是强引用的值。当ThreadLocal实例被回收比如置为null后由于key是弱引用会被GC回收但value的强引用还在如果线程一直不结束比如线程池中的线程这个value就永远无法被回收造成内存泄漏。避免方法用完一定要remove()就像我们上面在afterCompletion里做的那样。尽量将ThreadLocal变量声明为private static这样它的生命周期和ClassLoader绑定通常比线程更长避免了因ThreadLocal实例被回收而key为null的情况。如果使用线程池线程是会复用的所以每次任务执行完毕都必须清理ThreadLocal。3.3 浮点数精度与金额计算金融相关的计算比如订单金额、优惠券抵扣最忌讳用float或double。我踩过一个坑早期用double存储订单总价计算时出现了59.90000000000001这样的结果虽然差得不多但在对账时就是灾难。解决方案使用BigDecimal并且注意两点构造时一定要用字符串不要用double直接构造。// 错误精度已经丢失 BigDecimal a new BigDecimal(0.1); // 正确 BigDecimal b new BigDecimal(0.1);进行除法运算时必须指定精度和舍入模式否则遇到除不尽的情况会抛ArithmeticException。// 计算平均价格 BigDecimal total new BigDecimal(100); BigDecimal count new BigDecimal(3); // 指定保留2位小数采用四舍五入ROUND_HALF_UP BigDecimal avg total.divide(count, 2, RoundingMode.HALF_UP);我们在项目中所有涉及金额的字段在数据库中用DECIMAL(10,2)类型10位精度2位小数在Java实体类中用BigDecimal类型在传输时如JSON也序列化为字符串彻底杜绝精度问题。4. 面试复盘如何清晰表达你的项目经验最后结合我自己的面试和被面试经验聊聊怎么在面试中聊好“苍穹外卖”这类项目。面试官不想听流水账他想听的是你遇到什么问题你是怎么思考的你对比了哪些方案为什么选这个结果怎么样。STAR法则永远是好用的框架S情境我们项目初期用户量暴增高峰期订单接口响应慢数据库CPU飙升到90%。T任务我的任务是优化订单查询性能保证在秒级高峰下系统稳定。A行动我首先用Arthas做了线上诊断发现慢SQL集中在订单历史查询。然后我做了三件事第一针对用户订单列表引入了Redis缓存用用户ID分页参数作为key缓存5分钟第二对订单表创建了合理的复合索引user_id, create_time第三和DBA一起将订单表的历史数据归档到历史库减少主表数据量。R结果优化后订单查询接口的平均响应时间从800ms降到了120ms数据库CPU峰值下降了60%平稳度过了后续的促销活动。对于技术细节要能深挖 当你说“用了Redis缓存”面试官可能会问缓存键怎么设计的业务前缀:唯一标识如order:1001过期时间设多少为什么根据业务变更频率分类数据12小时热点数据5分钟缓存和数据库一致性怎么保证采用先更新数据库再删除缓存的最终一致策略对库存等强一致数据用分布式锁缓存穿透怎么办布隆过滤器空值缓存Redis集群方案一主两从三哨兵讲了哨兵选举过程展现你的技术视野和成长 不要只说你做了什么还要说你学到了什么如果重来你会怎么做。比如“当时为了快速上线缓存策略写得比较粗糙导致一些缓存键设计不合理后来引发了内存问题。如果重来我会在项目初期就引入Redis Key的前缀规范和内存监控告警。另外我现在觉得对于订单这种强状态流转的业务用CQRS命令查询职责分离架构可能更合适把读模型和写模型彻底分开读模型可以用Elasticsearch来承载复杂的查询这样架构会更清晰。”记住面试是双向的交流。你是在通过一个项目向面试官展示你的技术能力、解决问题的思路和潜力。把项目吃透把背后的原理想明白你就能自信地应对任何追问。