Shopee Java 二面 面经
二面和一面完全不同,二面基本不问八股,上来就问项目,然后是系统设计,最后才有几道深度技术题。整场面试节奏很慢但每个问题都很深,他会顺着你的回答一直追问,感觉是在考察你解决实际问题的能力,而不是背答案。
1. 介绍一下你做过的最复杂的项目,遇到的最难的技术问题是什么,怎么解决的?
答:(结合自身项目回答,以下是参考思路)
以一个电商订单系统为例,最难的问题是高并发下的库存超卖问题。
问题背景:秒杀活动时大量请求同时到来,数据库的乐观锁方案在高并发下冲突率极高,大量请求失败重试,反而加重了数据库压力,最终导致数据库连接池耗尽,整个服务不可用。
分析过程:用压测工具模拟秒杀场景,发现瓶颈在数据库层,乐观锁的 CAS 操作在高并发下失败率超过 90%,大量重试请求把数据库打满。
解决方案:把库存从数据库移到 Redis,用 Redis 的原子操作(DECR)做库存扣减,Redis 单线程保证原子性,不需要乐观锁。扣减成功后异步写入数据库,用消息队列解耦,数据库只需要处理最终的库存更新,不再是瓶颈。同时在 Redis 前加了一层本地缓存,用 Guava Cache 缓存库存是否充足的标志,大部分请求在本地就被过滤掉,不需要打到 Redis。
结果:QPS 从原来的几百提升到几万,数据库压力降低了 90% 以上。
2. 系统设计题:设计一个电商平台的购物车系统,需要支持高并发读写,用户数据需要持久化,你会怎么设计?
答:先明确需求:购物车的核心操作是添加商品、删除商品、修改数量、查看购物车。读多写少,用户浏览商品时频繁查看购物车,但修改操作相对少。数据需要持久化,用户关闭浏览器后再打开购物车还在。
数据模型设计:购物车本质是用户和商品的映射关系,每个用户有一个购物车,购物车里有若干商品条目,每个条目包含商品 ID、数量、加入时间等。
存储方案:用 Redis 的 Hash 结构存储购物车,key 是 cart:{userId},field 是商品 ID,value 是商品数量和其他信息的 JSON。Hash 结构天然适合购物车场景,可以 O(1) 地添加、删除、修改单个商品,也可以一次性获取整个购物车。
Redis 作为主存储,同时异步同步到 MySQL 做持久化。用户操作购物车时直接读写 Redis,定期或者在特定时机(用户下单前、用户登出时)把 Redis 里的数据同步到 MySQL。
这样设计的好处是读写都在 Redis,速度快,MySQL 只做持久化备份,不在请求链路上。
缓存一致性:Redis 和 MySQL 之间用消息队列异步同步,不保证强一致,只保证最终一致。如果 Redis 数据丢失(比如 Redis 重启),从 MySQL 恢复。
未登录用户的购物车:未登录用户用 cookie 里的临时 ID 标识,购物车存在 Redis 里,设置较短的过期时间(比如 7 天)。用户登录后,把临时购物车合并到账号购物车,相同商品数量相加,然后删除临时购物车。
商品信息的处理:购物车里只存商品 ID 和数量,不存商品名称、价格等信息,这些信息从商品服务实时查询。好处是商品信息变更时购物车不需要更新,坏处是查看购物车时需要额外查询商品信息。可以用本地缓存或者 Redis 缓存商品信息,减少查询开销。
容量限制:每个用户的购物车设置商品数量上限(比如 200 个),防止恶意用户创建超大购物车占用资源。
3. 你提到用了 Redis 做购物车,如果 Redis 集群发生脑裂,购物车数据会有什么问题,怎么处理?
答:脑裂是指 Redis 集群因为网络分区,主节点和从节点失去联系,从节点被选举为新主节点,但旧主节点还在继续接收写请求,导致两个主节点同时存在,数据出现分叉。
对购物车的影响:网络分区期间,部分客户端连接旧主节点,部分连接新主节点,两边的购物车数据独立更新。网络恢复后,旧主节点降为从节点,它上面的数据被新主节点的数据覆盖,这段时间内写入旧主节点的购物车操作全部丢失。
处理方案:
配置 min-slaves-to-write 和 min-slaves-max-lag:要求主节点至少有 N 个从节点在线且延迟不超过 M 秒才接受写请求。网络分区后旧主节点满足不了这个条件,会拒绝写请求,避免数据写入后丢失。代价是可用性降低,分区期间旧主节点不可写。
对于购物车这种场景,数据丢失的影响相对可控,用户重新添加商品即可,不像支付数据那么敏感。可以在客户端做乐观处理,操作失败时提示用户重试,同时记录操作日志,方便排查问题。
更根本的解决方案是用 Redis Cluster 模式,数据分片存储在多个节点,每个分片有主从,脑裂的影响范围限制在单个分片,不影响整个集群。
4. 讲一下 MySQL 的 MVCC 在读已提交和可重复读隔离级别下的区别,ReadView 是什么时候创建的?
答:MVCC 的核心是 ReadView,ReadView 记录了创建时刻所有活跃事务的 ID 列表,用于判断哪个版本的数据对当前事务可见。
两个隔离级别的区别在于 ReadView 的创建时机:
读已提交:每次执行 SELECT 语句时都创建一个新的 ReadView。这意味着同一个事务里,两次 SELECT 之间如果有其他事务提交了,第二次 SELECT 会看到新提交的数据,因为新的 ReadView 不再把那个事务列为活跃事务。这就是为什么读已提交会出现不可重复读。
可重复读:只在事务第一次执行 SELECT 时创建 ReadView,后续所有 SELECT 都复用这个 ReadView。这样整个事务期间看到的数据版本是固定的,其他事务的提交不影响当前事务的读结果,实现了可重复读。
一个细节:可重复读下,事务开始(BEGIN)时不会立即创建 ReadView,而是第一次执行快照读(普通 SELECT)时才创建。如果事务开始后先执行了当前读(SELECT FOR UPDATE),再执行快照读,ReadView 是在快照读时创建的,这时候当前读已经看到了最新数据,可能和后续快照读的结果不一致,需要注意。
5. 讲一下分布式事务,你了解哪些解决方案,各自的适用场景是什么?
答:分布式事务的核心难点是跨多个服务或数据库的操作需要保证原子性,要么全部成功要么全部回滚,但网络不可靠,任何一步都可能失败。
两阶段提交(2PC):协调者先向所有参与者发送 Prepare 请求,参与者执行操作但不提交,返回 Yes 或 No。如果所有参与者都返回 Yes,协调者发送 Commit,否则发送 Rollback。
问题:协调者是单点,协调者挂了整个事务阻塞。第二阶段协调者发送 Commit 后挂了,部分参与者提交了部分没有,数据不一致。性能差,参与者在 Prepar
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
Java面试圣经,带你练透java圣经
查看11道真题和解析