1. 项目概述与高频面试问题解析
黑马点评项目是一个典型的本地生活服务平台,采用前后端分离架构,后端基于SpringBoot构建单体服务,前端使用Vue实现。项目核心解决了高并发场景下的多个技术难题,包括秒杀超卖、缓存异常、分布式锁等。作为面试准备的重点项目,它涵盖了Java后端开发中的诸多核心技术点。
1.1 项目架构与技术栈
项目整体架构采用分层设计:
- 前端层:Vue.js + ElementUI
- 接入层:Nginx(反向代理+负载均衡+动静分离)
- 服务层:SpringBoot + MyBatis-Plus
- 数据层:MySQL(主从架构)+ Redis(缓存+分布式锁+消息队列)
- 辅助组件:Redisson + Lua脚本 + 拦截器
提示:在面试中描述架构时,建议按照"从外到内"的顺序讲解,先讲用户请求如何进入系统,再讲内部处理流程,最后讲数据存储,这样逻辑更清晰。
1.2 核心业务流程
主要业务功能模块包括:
- 用户认证:手机号验证码登录+Redis Session共享
- 商户服务:附近商户查询+Redis GEO位置服务
- 优惠券服务:普通券+秒杀券(重点)
- 博客服务:探店笔记+点赞功能
- 社交服务:关注+共同关注
- 数据统计:签到+UV统计
2. Redis核心应用与高并发解决方案
2.1 缓存异常处理方案
2.1.1 缓存穿透解决方案
问题本质:请求不存在的数据,绕过缓存直接访问数据库。
解决方案:
- 缓存空对象:对查询为空的结果缓存空值,设置短过期时间(2分钟)
java复制// 伪代码示例
public Shop queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从Redis查询缓存
String shopJson = redisTemplate.opsForValue().get(key);
// 2. 判断缓存是否存在
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
// 3. 命中空值缓存
if (shopJson != null) {
return null;
}
// 4. 查询数据库
Shop shop = getById(id);
// 5. 数据库不存在,缓存空值
if (shop == null) {
redisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES);
return null;
}
// 6. 数据库存在,写入缓存
redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES);
return shop;
}
- 布隆过滤器(项目未采用但可提及):适用于固定数据集,用位数组预存所有有效ID
2.1.2 缓存击穿解决方案
问题本质:热点key突然失效,大量请求直接访问数据库。
两种实现方案对比:
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 使用setnx命令获取锁,只有一个线程能重建缓存 | 实现简单,保证强一致性 | 有线程等待,性能中等 | 普通热点数据 |
| 逻辑过期 | 缓存不设置过期时间,value中包含逻辑过期字段 | 性能极高,无等待 | 实现复杂,有短暂不一致 | 超高并发场景 |
逻辑过期方案核心代码结构:
java复制@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 实际数据
}
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 从Redis查询缓存
String json = redisTemplate.opsForValue().get(key);
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = (Shop) redisData.getData();
// 2. 判断是否逻辑过期
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 2.1 未过期,直接返回
return shop;
}
// 2.2 已过期,尝试获取锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 3. 获取锁成功,开启独立线程重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);
} finally {
unlock(lockKey);
}
});
}
// 4. 返回过期数据
return shop;
}
2.1.3 缓存雪崩解决方案
问题本质:大量key同时失效或Redis宕机。
解决方案:
- 随机过期时间:基础过期时间+随机值(0-5分钟)
- 多级缓存:Nginx本地缓存+Redis集群+JVM缓存
- 服务降级:Sentinel或Hystrix保护数据库
2.2 分布式锁深度解析
2.2.1 Redisson分布式锁实现
Redisson锁核心特性:
- 可重入性:同一线程可多次获取同一把锁
- WatchDog机制:自动续期,防止业务未执行完锁过期
- 锁重试:获取锁失败后等待重试
- 超时释放:避免死锁
锁使用示例:
java复制// 获取锁
RLock lock = redissonClient.getLock("lock:order:" + userId);
try {
// 尝试获取锁,参数:等待时间,锁自动释放时间,时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
// 获取锁失败
return Result.fail("不允许重复下单");
}
// 执行业务逻辑
createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}
2.2.2 锁的注意事项
- 锁的粒度要细:以用户ID为维度而不是全局锁
- 避免误删锁:value中存储唯一标识(UUID+线程ID)
- 原子性操作:判断锁归属和释放锁要用Lua脚本保证原子性
- 合理设置超时:业务执行时间 < 锁过期时间
3. 秒杀系统设计与实现
3.1 秒杀架构设计
三级缓冲架构:
- 前端层:静态资源CDN+按钮防重复点击
- 接入层:Nginx限流+负载均衡
- 服务层:
- Redis库存预热
- Lua脚本原子校验
- 消息队列削峰
- 异步下单
3.2 核心流程实现
秒杀流程图解:
- 用户发起秒杀请求
- Lua脚本原子执行:
- 库存判断
- 一人一单校验
- 库存扣减
- 订单信息入队
- 返回秒杀结果
- 异步线程消费消息创建订单
Lua脚本示例:
lua复制-- 参数:优惠券ID、用户ID、订单ID
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
-- 库存key和订单key
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
-- 1. 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1
end
-- 2. 判断用户是否已经下单
if (redis.call('sismember', orderKey, userId) == 1) then
return 2
end
-- 3. 扣减库存、保存用户、发送消息
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
3.3 性能优化关键点
- 库存预热:活动开始前将库存加载到Redis
- 原子操作:使用Lua脚本保证校验+扣减的原子性
- 异步下单:消息队列削峰填谷
- 多级缓存:Redis+本地缓存减少IO
实测性能对比:
- 优化前:200 RPS,500ms响应
- 优化后:2000 RPS,100ms响应
4. 其他核心功能实现
4.1 点赞功能实现
技术方案:
- 使用Redis的ZSet结构
- Key设计:
blog:liked:{笔记ID} - Value:用户ID
- Score:点赞时间戳
核心命令:
bash复制# 点赞
ZADD blog:liked:1 1630000000 101
# 取消点赞
ZREM blog:liked:1 101
# 获取点赞排行榜
ZRANGE blog:liked:1 0 4
4.2 共同关注实现
技术方案:
- 使用Redis的Set结构
- Key设计:
follows:{用户ID} - 核心命令:
bash复制# 添加关注
SADD follows:101 102
# 取消关注
SREM follows:101 102
# 共同关注
SINTER follows:101 follows:102
4.3 签到功能实现
技术方案:
- 使用Redis的BitMap结构
- Key设计:
sign:{用户ID}:{年月} - 实现代码:
java复制public Result sign() {
// 1. 获取当前登录用户
Long userId = UserHolder.getUser().getId();
// 2. 获取日期
LocalDateTime now = LocalDateTime.now();
// 3. 拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
// 4. 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
// 5. 写入Redis SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
5. 面试常见问题与解决方案
5.1 典型问题排查记录
-
分布式锁误删问题:
- 现象:出现重复下单
- 原因:线程A锁过期后,线程B获取锁,线程A执行完删除线程B的锁
- 解决:增加锁标识校验+Lua脚本保证原子性
-
缓存一致性异常:
- 现象:商户信息更新后前端显示旧数据
- 原因:数据库更新后缓存删除失败
- 解决:增加重试机制+设置合理过期时间
5.2 性能优化经验
-
接口压测要点:
- 使用JMeter进行阶梯式压测
- 监控Redis和MySQL的QPS、CPU使用率
- 重点关注慢查询和Full GC
-
优化效果对比:
- 商户查询接口:500ms → 50ms
- 秒杀接口:200 RPS → 2000 RPS
- 缓存命中率:60% → 95%
5.3 项目演进方向
- 架构升级:
- 服务拆分:商户服务、订单服务、用户服务
- 引入SpringCloud生态
- 数据扩展:
- 分库分表:订单表按用户ID分片
- 读写分离:MySQL主从架构
- 高可用:
- Redis集群:哨兵模式
- 服务熔断:Sentinel
在实际面试中,建议结合项目中的具体场景来回答问题,避免泛泛而谈。对于每个技术点,最好能说出你的思考过程和选择理由,这比单纯背诵标准答案更能打动面试官。