news 2026/9/16 15:39:29

黑马点评毕业设计实战:从单体架构到高并发点评系统的演进

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
黑马点评毕业设计实战:从单体架构到高并发点评系统的演进

最近在帮学弟学妹们看“黑马点评”这类毕业设计项目,发现一个挺普遍的现象:大家把功能点(登录、店铺查询、点赞、评论、下单)都做出来了,界面也像模像样,但一问到“如果同时有1000个人抢购一张优惠券怎么办?”或者“店铺信息每天被查几万次,数据库受得了吗?”,很多项目就露怯了。功能堆砌不难,难的是让系统能抗住真实世界的“压力”。今天,我就结合一次实战重构,聊聊怎么把一个单体架构的点评系统,一步步演进成一个能应对一定并发量的、更健壮的系统。

1. 学生项目里那些“看不见”的坑

先别急着想用什么炫酷技术,咱们得把常见的问题靶子立起来。从我review过的项目来看,下面这几个坑几乎一踩一个准:

  1. “裸奔”的数据库查询:这是最典型的。查询店铺详情、热门商品列表,全是直接SELECT * FROM shop WHERE id = ?。在毕业答辩演示时,数据量小,完全没感觉。但稍微有点并发,比如首页同时打开,数据库连接瞬间被打满,页面加载就会变得奇慢无比,甚至直接超时“挂掉”。
  2. 并发操作的“想当然”处理:比如“点赞”功能。常见的实现是:先查询用户是否点过赞,如果没有,就给like_count字段+1。伪代码大概是if(!liked){ update table set count=count+1}。这在两个人同时点赞时,很可能导致计数只增加了1,因为两个线程都读到了未点赞的状态。这就是典型的并发更新丢失问题。
  3. 脆弱的口令与权限管理:很多项目直接用HttpSession存登录状态,这在单体应用里没问题。但一旦后续想扩展成多台服务器(比如用Nginx做负载均衡),Session不共享,用户在这台服务器登录,请求被转发到另一台,就又提示未登录了。另外,用户ID、权限信息直接从Session里拿,代码里充斥着(Long)session.getAttribute("user"),既不安全,也让业务逻辑与Web容器耦合过紧。
  4. 对“超卖”问题无感知:优惠券秒杀、限量商品抢购是点评类系统的常见场景。很多项目的下单逻辑是:1)查询库存stock > 0; 2)创建订单; 3)扣减库存update set stock=stock-1。在高并发下,多个线程同时查到库存为1,都认为自己能下单,最终会导致库存被扣成负数,这就是超卖。

2. 关键技术选型:为什么是它们?

面对这些问题,我们需要引入一些“外援”。选型的原则是:简单、有效、社区成熟。

  1. 持久层:MyBatis-Plus 优于 “裸” MyBatis

    • “裸”MyBatis:需要手写大量简单CRUD的SQL和XML文件,对于毕业设计这种表结构不算复杂的项目,属于重复劳动。
    • MyBatis-Plus:它是在MyBatis基础上的增强。最大好处是内置了通用Mapper和Service,像单表的增删改查,一两行代码就搞定。比如分页查询、逻辑删除这些常用功能,配置一下就能用。这能让我们把精力更集中在复杂的业务SQL性能优化上,而不是基础的insert into。对于快速构建和保持代码整洁,MP优势明显。
  2. 缓存:Redis 绝对优于 本地缓存(如Caffeine)

    • 本地缓存:数据存在应用服务器内存里,访问速度极快。但致命问题是:1)容量有限,不能存太多数据;2)数据不一致,在集群部署时,A服务器更新了缓存,B服务器不知道,用户看到的就是旧数据。
    • Redis:独立的分布式缓存中间件。所有服务器都访问同一个Redis,数据天然一致。而且Redis支持丰富的数据结构(String, Hash, Set, List, SortedSet),正好对应我们不同的业务场景。比如用String缓存店铺详情,用Set存储用户点赞列表实现去重,用SortedSet做排行榜。对于需要数据一致性和共享的缓存场景,Redis是唯一选择。

3. 核心模块实战:代码怎么说人话?

理论说再多,不如看代码。我们挑三个最核心的模块,看看具体怎么落地。

模块一:登录态管理(JWT + ThreadLocal)

目标:实现无状态登录,让服务端集群化部署无忧。

  • 思路:用户登录成功后,不存Session,而是用一个加密的JSON字符串(JWT Token)作为“身份证”返回给前端。前端后续请求都在Header中带上这个Token。服务端用一个拦截器(Interceptor)来校验Token的合法性和有效性,并将解析出的用户信息(如userId)存入ThreadLocal,这样在当前请求线程的任何地方,都能方便地获取用户信息,而不用反复解析Token。

  • 关键代码片段

// 1. 登录成功,生成Token (JwtUtils是一个自定义工具类) public String login(User user) { // ... 验证用户名密码 ... String token = JwtUtils.createToken(user.getId(), user.getUsername()); // 可以将token与userId的对应关系存入Redis,实现主动失效(如修改密码后踢下线) redisTemplate.opsForValue().set("login:token:" + user.getId(), token, 30, TimeUnit.MINUTES); return token; } // 2. 拦截器校验并设置用户信息 public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 1. 从Header获取token String token = request.getHeader("authorization"); // 2. 校验token是否有效(是否为空、格式对、签名正确、未过期) if (!JwtUtils.verifyToken(token)) { // 返回401未授权 response.setStatus(401); return false; } // 3. 解析token,获取用户信息 Long userId = JwtUtils.getUserIdFromToken(token); // 4. 将用户ID存入ThreadLocal UserHolder.saveUser(userId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束后,清除ThreadLocal中的数据,防止内存泄漏 UserHolder.removeUser(); } } // 3. UserHolder (ThreadLocal工具类) public class UserHolder { private static final ThreadLocal<Long> tl = new ThreadLocal<>(); public static void saveUser(Long userId){ tl.set(userId); } public static Long getUser(){ return tl.get(); } public static void removeUser(){ tl.remove(); } } // 4. 在业务中直接使用 @PostMapping("/like") public Result likeBlog(@RequestParam Long blogId) { // 直接从ThreadLocal取,无需再解析请求 Long userId = UserHolder.getUser(); blogService.likeBlog(blogId, userId); return Result.ok(); }

模块二:点赞去重(Redis Set)

目标:确保一个用户对同一篇文章只能点赞一次,且要高效。

  • 思路:利用Redis的Set集合元素不重复的特性。为每篇文章(blogId)维护一个Set,里面存储所有点赞用户的ID。用户点赞时,执行SADD key userId,如果返回1表示点赞成功(新增元素),如果返回0表示已点过赞(元素已存在)。取消点赞则用SREM。查询用户是否点赞用SISMEMBER。查询点赞总数用SCARD

  • 关键代码片段

@Service public class BlogServiceImpl implements IBlogService { @Autowired private StringRedisTemplate redisTemplate; @Override public Result likeBlog(Long blogId, Long userId) { String key = "blog:liked:" + blogId; // 1. 判断当前用户是否已经点赞 Boolean isMember = redisTemplate.opsForSet().isMember(key, userId.toString()); if (Boolean.TRUE.equals(isMember)) { // 已点赞,取消点赞 redisTemplate.opsForSet().remove(key, userId.toString()); // 数据库点赞数-1 (这里省略了DB更新,可以异步进行) // update blog set liked = liked - 1 where id = #{blogId} } else { // 未点赞,执行点赞 redisTemplate.opsForSet().add(key, userId.toString()); // 数据库点赞数+1 // update blog set liked = liked + 1 where id = #{blogId} } return Result.ok(); } @Override public Result getBlogLikes(Long blogId) { String key = "blog:liked:" + blogId; // 获取点赞用户ID集合 Set<String> userIds = redisTemplate.opsForSet().members(key); // 获取点赞总数 Long size = redisTemplate.opsForSet().size(key); // ... 组装返回数据 ... return Result.ok(data); } }

模块三:店铺缓存策略(Cache-Aside + 空值缓存)

目标:极大减轻数据库压力,同时防止缓存穿透。

  • 思路:采用最常用的Cache-Aside(旁路缓存)模式。读请求:先读缓存,命中则返回;未命中则读数据库,写入缓存,再返回。写请求:先更新数据库,再删除缓存(注意:是删除,不是更新,避免复杂的并发更新缓存问题)。对于缓存穿透(查询一个根本不存在的数据),用“空值缓存”来解决:即使从数据库没查到,也在Redis里存一个空值(如""null)并设置一个较短的过期时间,这样后续相同请求就会命中这个空值缓存,而不会再到数据库。

  • 关键代码片段

@Service public class ShopServiceImpl implements IShopService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private ShopMapper shopMapper; // MyBatis-Plus的Mapper private static final String CACHE_KEY_PREFIX = "cache:shop:"; private static final long CACHE_NULL_TTL = 2L; // 空值缓存2分钟 @Override public Result queryById(Long id) { String key = CACHE_KEY_PREFIX + id; // 1. 从Redis查询店铺缓存 String shopJson = redisTemplate.opsForValue().get(key); // 2. 判断缓存是否存在(注意:空值也是存在) if (StrUtil.isNotBlank(shopJson)) { // 3. 存在,且不是空值,直接返回 Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 命中空值缓存(shopJson是"") if (shopJson != null) { return Result.fail("店铺不存在!"); } // 4. 缓存不存在,查询数据库 Shop shop = shopMapper.selectById(id); // 5. 数据库不存在,将空值写入Redis,防止缓存穿透 if (shop == null) { redisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return Result.fail("店铺不存在!"); } // 6. 数据库存在,写入Redis,设置正常TTL(如30分钟) redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30, TimeUnit.MINUTES); // 7. 返回 return Result.ok(shop); } @Override @Transactional public Result update(Shop shop) { Long id = shop.getId(); if (id == null) { return Result.fail("店铺ID不能为空"); } // 1. 更新数据库 shopMapper.updateById(shop); // 2. 删除缓存 String key = CACHE_KEY_PREFIX + id; redisTemplate.delete(key); return Result.ok(); } }

4. 效果如何?用JMeter压测说话

重构不能凭感觉,得有数据支撑。我用JMeter对“查询店铺详情”这个接口做了简单的对比压测。

  • 场景:模拟100个线程,在10秒内启动,持续运行30秒,循环查询不同的店铺ID。
  • 对照组(无缓存):所有请求直接打到MySQL。
  • 实验组(有Redis缓存):第一次查询后,数据被缓存。

结果对比

  • 吞吐量(QPS):无缓存时平均QPS约150,加入Redis缓存后,平均QPS提升到500+,提升了3倍以上。这是因为绝大多数请求都在Redis内存中完成,速度极快。
  • 错误率:无缓存时,在持续高并发下,由于数据库连接池耗尽,开始出现连接超时错误。有缓存后,错误率接近0%
  • 响应时间:无缓存时,平均响应时间在200ms左右,且有长尾延迟(个别请求超过1秒)。有缓存后,平均响应时间稳定在20ms以内

这个测试虽然简单,但清晰地证明了缓存对系统性能的决定性影响。在你的毕业设计答辩中,如果能展示这样一组对比数据,说服力会强很多。

5. 生产环境避坑指南(进阶思考)

把系统跑起来只是第一步,要让它稳定运行,还得考虑更多。

  1. 缓存穿透防护:我们上面用了“空值缓存”,这能解决大部分问题。对于恶意攻击用不同ID轮询的情况,可以考虑使用布隆过滤器(Bloom Filter)在查询缓存前先做一层过滤,判断ID是否可能存在,如果布隆过滤器说“不存在”,那一定不存在,直接返回,保护了缓存和数据库。
  2. Token续期机制:JWT Token过期了怎么办?让用户重新登录体验很差。常见的做法是:在Token快过期时(比如在拦截器里发现剩余有效期小于10分钟),在返回的响应Header中塞入一个新的Token。前端检测到有新Token就替换旧的。这实现了“无感刷新”。
  3. 接口幂等性处理:对于点赞、下单这类操作,网络超时可能导致前端重复提交。我们的点赞用Redis Set的SADD天然是幂等的(重复加同一用户返回0)。对于下单,可以在请求时带上一个唯一凭证(如UUID),服务器用Redis记录这个凭证是否已处理过,实现“一张票只验一次”。
  4. 分布式锁解决超卖:这是我们开头提到的大坑。用Redis的setnx命令(或Redisson客户端)可以实现一个分布式锁。秒杀扣减库存时,先抢锁,抢到锁的线程执行“查询+判断+扣减”的整个流程,执行完释放锁。这样就能保证库存检查和高并发更新是串行化的,彻底解决超卖。这是面试中非常高频的问题。

写在最后

这次从“功能实现”到“并发设计”的重构过程,让我感觉像是给一个毛坯房做了一次精装修。不仅住着更舒服(性能好),结构也更稳固了(健壮性高)。对于毕业设计或者个人项目来说,在完成基本功能后,主动去思考并解决这些高并发、高可用的问题,是一个非常好的进阶路径。

最后留一个思考题:如果我们要把这个点评系统升级,加入“附近好评店铺推荐”的功能(LBS,基于地理位置的服务),架构上可以怎么做?是不是可以引入Redis的GEO数据类型来存储店铺坐标,实现快速的地理位置查询?又或者,当数据量极大时,是否要考虑用专门的时空数据库?技术的演进总是伴随着业务的需求,而我们的系统设计,也需要为这种演进留出空间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 5:00:49

渔人的直感:FF14钓鱼效率革命工具

渔人的直感&#xff1a;FF14钓鱼效率革命工具 【免费下载链接】Fishers-Intuition 渔人的直感&#xff0c;最终幻想14钓鱼计时器 项目地址: https://gitcode.com/gh_mirrors/fi/Fishers-Intuition 1. 钓鱼困境破解&#xff1a;三大核心痛点解析 在艾欧泽亚的水域中&…

作者头像 李华
网站建设 2026/9/15 3:54:54

ESP32 HUB75 LED矩阵DMA驱动技术指南:从零构建高性能显示系统

ESP32 HUB75 LED矩阵DMA驱动技术指南&#xff1a;从零构建高性能显示系统 【免费下载链接】ESP32-HUB75-MatrixPanel-DMA An Adafruit GFX Compatible Library for the ESP32, ESP32-S2, ESP32-S3 to drive HUB75 LED matrix panels using DMA for high refresh rates. Support…

作者头像 李华
网站建设 2026/9/15 12:53:31

解决HTML转图片难题的Python工具:html2image高效实现指南

解决HTML转图片难题的Python工具&#xff1a;html2image高效实现指南 【免费下载链接】html2image A package acting as a wrapper around the headless mode of existing web browsers to generate images from URLs and from HTMLCSS strings or files. 项目地址: https://…

作者头像 李华