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 weight=2; # weight表示权重,这台机器处理2份请求 server 192.168.1.102:8080 weight=1; # 这台处理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; } }面试高频问题1:Nginx负载均衡策略有哪些?你们用的哪种?为什么?
除了默认的轮询(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集群,将数据分散到不同节点,降低单个节点宕机的影响。
- 我们的解决方案:给缓存过期时间加上随机值。比如原本缓存1小时,现在改成
缓存击穿:某个热点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); // 简单重试,生产环境需控制次数 } }- 我们的解决方案:互斥锁(Mutex Lock)。当第一个发现缓存过期的请求到达时,它去获取一个分布式锁(我们用Redis的
面试高频问题4:Redis为什么快?
- 纯内存操作:数据都在内存里,读写速度是纳秒级,磁盘是毫秒级,差了好几个数量级。
- 单线程模型(核心网络请求处理):避免了多线程上下文切换和竞争的开销。虽然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?useUnicode=true 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和可接受的误判率fpp(false positive probability),库会自动计算所需的m和k。
// 使用Guava布隆过滤器 BloomFilter<String> 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在低并发下没问题,但它不是原子的“查询+更新”吗?在高并发下,多个线程可能同时读到stock=1,然后都执行了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 ThreadLocal<Long> 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来标记一个“事务上下文”,确保在同一个线程内,多个数据库操作共享同一个连接和事务。
面试高频问题9:ThreadLocal为什么会内存泄漏?如何避免?这是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来承载复杂的查询,这样架构会更清晰。”
记住,面试是双向的交流。你是在通过一个项目,向面试官展示你的技术能力、解决问题的思路和潜力。把项目吃透,把背后的原理想明白,你就能自信地应对任何追问。