黑马点评毕业设计技术解析:从单体架构到高并发点评系统的演进之路
摘要:很多学生在完成“黑马点评”毕业设计时,常陷入数据库瓶颈、缓存穿透、接口幂等性缺失等典型问题。本文基于真实教学项目,系统讲解如何通过 Redis 缓存预热、分布式锁控制热点评论、异步消息队列解耦写操作等关键技术,构建一个具备基础高并发能力的点评系统。读者将掌握可落地的性能优化策略与工程化思维,显著提升系统吞吐量与稳定性。
1. 项目背景与常见痛点
“黑马点评”脱胎于教学场景,功能看似简单:用户注册登录、浏览商户、发评论、点赞/收藏。一旦把并发量从“一个人点一下”提升到“一千个人同时点”,单体架构的短板立刻暴露:
- 缓存击穿:热点商户信息过期瞬间,大量请求直穿 DB,CPU 飙高。
- 超卖:优惠券或秒杀库存未做原子扣减,出现“-1 件”。
- 冷启动慢:服务重启后缓存为空,接口 RT 从 50 ms 涨到 2 s。
- 幂等缺失:用户疯狂点击“点赞”,Redis 计数飙涨,DB 却重复插入。
- 事务边界混乱:Service 层一个方法里既调 Redis 又调 DB,回滚时缓存与数据库不一致。
这些问题在毕业答辩的 PPT 里往往被一句“后续优化”带过,真到演示时却频频 5xx。下面把踩过的坑逐一展开,给出可直接复制的工程级方案。
2. 技术选型对比:教学版 vs 生产版
| 维度 | 教学默认 | 生产推荐 | 一句话理由 |
|---|---|---|---|
| ORM | MyBatis | MyBatis-Plus | 内置分页、乐观锁、LambdaWrapper,代码量↓ 40% |
| Redis | 单机 6379 | 三主三从 + Sentinel | 毕设答辩时老师随手关虚拟机,主从切换秒级完成 |
| MQ | 无 | RocketMQ/RabbitMQ | 点赞、写评论异步落库,DB 压力降 70% |
| 限流 | 无 | Bucket4j + Redis | 低成本令牌桶,突发流量 QPS 可控 |
| 序列化 | JDK | Protostuff/Kryo | Redis 序列化体积减半,网络 IO↓ 30% |
注意:教学版到生产版不是“一步替换”,而是“逐步灰度”。先让系统能跑通,再让系统跑得稳。
3. 核心模块实现细节
以下代码均基于 Spring Boot 2.7 + MyBatis-Plus + Redis 6.2,遵守 Clean Code 原则:方法名自解释、魔法值用常量、分支深度≤2。
3.1 用户登录:基于 Redis 的分布式会话
需求:支持多端登录、token 自动续期、踢人下线。
- 登录成功后生成 UUID 作为 token,以
user:token:{userId}:{uuid}写入 Redis,TTL=30 min。 - 每次请求带 token,网关层通过
UserContextHolder填充 ThreadLocal。 - 续期策略:接口返回前异步刷新 TTL,避免“半小时必掉线”尴尬。
@RestController @RequestMapping("/api/user") public class UserController { @Resource private StringRedisTemplate redisTpl; private static final String TOKEN_PREFIX = "user:token:"; private static final Duration TOKEN_TTL = Duration.ofMinutes(30); @PostMapping("/login") public ApiResult<String> login(@RequestBody LoginForm form) { // 1. 参数校验省略 User user = userService.verify(form.getPhone(), form.getPassword()); String uuid = IdUtil.fastUUID(); String key = TOKEN_PREFIX + user.getId() + ":" + uuid; redisTpl.opsForValue().set(key, String.valueOf(user.getId()), TOKEN_TTL); return ApiResult.ok(uuid); // 返回 token } }关键点:token 里带 userId,可防止恶意遍历;UUID 段保证同一用户多端登录互不影响。
3.2 商户缓存:双重判定锁解决击穿
需求:商户信息读多写少,并发查询 5k+ QPS。
- 缓存空对象:DB 查不到也写 Redis,防止重复穿透。
- 双重判定:第 1 次判空→加锁→第 2 次判空,减少锁竞争。
- 热点自动续期:Worker 每 10 min 扫描访问量 Top 100 的 key,异步续期。
public Shop getShop(Long id) { String key = CACHE_SHOP_KEY + id; Shop shop = redisTpl.opsForValue().get(key); if (shop != null) { return shop; } // 第一层判定 String lockKey = LOCK_SHOP_KEY + id; Boolean locked = redisTpl.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 第二层判定 shop = redisTpl.opsForValue().get(key); if (shop != null) { return shop; } shop = shopMapper.selectById(id); if (shop == null) { // 缓存空对象 redisTpl.opsForValue().set(key, new Shop(), Duration.ofMinutes(2)); } else { redisTpl.opsForValue().set(key, shop, Duration.ofMinutes(30)); } } finally { redisTpl.delete(lockKey); } } else { // 50ms 后重试,避免大量线程阻塞 ThreadUtil.sleep(50); return getShop(id); } return shop; }3.3 点赞/收藏:基于 Redis 的幂等计数
需求:用户点赞后 1 s 内连续点击只算一次;计数实时展示。
- 使用
SETNX做幂等标记,key 带业务前缀like:uid:{userId}:bid:{blogId},TTL=2 s。 - 计数用
HINCRBY,字段blog:{blogId}:like。 - 定时任务每 5 s 把 Redis 计数批量写回 DB,减小写压力。
public void like(Long userId, Long blogId) { String idempotentKey = "like:uid:" + userId + ":bid:" + blogId; Boolean absent = redisTpl.opsForValue().setIfAbsent(idempotentKey, "1", Duration.ofSeconds(2)); if (Boolean.FALSE.equals(absent)) { throw new BizException("操作太快,请稍后再试"); } redisTpl.opsForHash().increment("blog:" + blogId + ":like", "cnt", 1); }说明:SETNX 原子性保证并发点击只通过一次;HINCRBY 原子自增,无需事务。
4. 性能与安全考量
防刷限流
- 网关层整合 Bucket4j,令牌桶容量 200,填充速率 100/s,超出直接返回 429。
- 针对短信验证码接口再叠加手机号维度桶,1 分钟 3 次,防止短信轰炸。
SQL 注入防护
- MyBatis-Plus 内置
#{}预编译,额外开启全局关键字过滤,拦截sleep、benchmark等危险函数。 - 模糊查询使用
QueryWrapper的like()方法,禁止拼接%${}%。
- MyBatis-Plus 内置
缓存一致性
- 写操作先删缓存再改 DB,延迟双删策略:第二次延迟 500 ms 再次删除,兜底并发脏读。
- 使用 Canal 监听 binlog,异步修正 Redis,做到“最终一致”。
接口幂等
- 除点赞外,订单型写操作统一带
Idempotency-Token头,网关层校验 Redis 是否已存在,保证重复提交只处理一次。
- 除点赞外,订单型写操作统一带
5. 生产环境避坑指南
Redis Key 设计规范
- 格式:
业务:子业务:唯一标识:维度,全部小写,用冒号分隔。 - 禁止
keys *,统一用scan + TYPE扫描;长度≤ 44 字节,减少内存占用。
- 格式:
事务边界划分
- 只把“必须原子”的 DB 操作包进
@Transactional,缓存操作放外层,防止回滚后缓存已写。 - 跨 Redis 与 DB 的“混合事务”用最终一致思路,不要企图用 Redis 事务(Lua)包裹 DB。
- 只把“必须原子”的 DB 操作包进
序列化与版本兼容
- 对象缓存放 Protostuff,新增字段必须
serialVersionUID自增,防止升级后反序列化失败。 - 开启
spring.redis.serializer=GenericJackson2JsonRedisSerializer时,记得把类名写入 JSON,避免包路径调整导致找不到类。
- 对象缓存放 Protostuff,新增字段必须
慢 SQL 治理
- 打开 MyBatis-Plus 的
performanceInterceptor,阈值 100 ms,超阈值自动打印完整 SQL 与参数。 - 商户模糊查询走 ES,只把 ID 回表,避免
%xx%导致全表扫描。
- 打开 MyBatis-Plus 的
容器线程数
- Undertow 默认 IO 线程数 = CPU*2,业务线程池另配
core=CPU*4,防止 IO 线程被阻塞。
- Undertow 默认 IO 线程数 = CPU*2,业务线程池另配
6. 高并发验证与扩展思考
资源受限条件下,可用以下低成本方案模拟 1 k 并发:
- 本地起 Docker 版 Redis、MySQL,关闭持久化,纯内存跑。
- 用 Gatling/JMeter 起 2 个线程组,每个 500 并发,循环 30 s,观察 95 线 RT 与错误率。
- 重点指标:缓存命中率 ≥ 90%、QPS ≥ 1500、DB 连接 ≤ 20、CPU ≤ 70%。
扩展练习:地理位置附近商户推荐
把商户坐标导入 RedisGEO结构,用户上传经纬度后使用GEORADIUS命令 5 km 内搜索,再结合评分排序,即可实现“附近好评最多”功能。记得给坐标 key 设置过期时间,防止冷数据常驻。
写完这篇笔记,最大的感受是:毕业设计不是“跑通”就行,而是要把“跑通”到“跑稳”的每一步都留下可回滚的 Git 记录。把缓存、幂等、限流这些看似“高级”的词拆成一行行代码,才发现高并发系统其实是由无数个小细节累加而成。希望这些可直接粘贴的片段,能帮你在答辩现场少踩几个 5xx,把更多时间留给老师提问——“如果 Redis 挂了,你的系统还能降级吗?” 不妨现在就动手,把答案写进代码里。