最近在帮学弟学妹们看“黑马点评”这类毕业设计项目,发现一个挺普遍的现象:大家把功能点(登录、店铺查询、点赞、评论、下单)都做出来了,界面也像模像样,但一问到“如果同时有1000个人抢购一张优惠券怎么办?”或者“店铺信息每天被查几万次,数据库受得了吗?”,很多项目就露怯了。功能堆砌不难,难的是让系统能抗住真实世界的“压力”。今天,我就结合一次实战重构,聊聊怎么把一个单体架构的点评系统,一步步演进成一个能应对一定并发量的、更健壮的系统。
1. 学生项目里那些“看不见”的坑
先别急着想用什么炫酷技术,咱们得把常见的问题靶子立起来。从我review过的项目来看,下面这几个坑几乎一踩一个准:
- “裸奔”的数据库查询:这是最典型的。查询店铺详情、热门商品列表,全是直接
SELECT * FROM shop WHERE id = ?。在毕业答辩演示时,数据量小,完全没感觉。但稍微有点并发,比如首页同时打开,数据库连接瞬间被打满,页面加载就会变得奇慢无比,甚至直接超时“挂掉”。 - 并发操作的“想当然”处理:比如“点赞”功能。常见的实现是:先查询用户是否点过赞,如果没有,就给
like_count字段+1。伪代码大概是if(!liked){ update table set count=count+1}。这在两个人同时点赞时,很可能导致计数只增加了1,因为两个线程都读到了未点赞的状态。这就是典型的并发更新丢失问题。 - 脆弱的口令与权限管理:很多项目直接用
HttpSession存登录状态,这在单体应用里没问题。但一旦后续想扩展成多台服务器(比如用Nginx做负载均衡),Session不共享,用户在这台服务器登录,请求被转发到另一台,就又提示未登录了。另外,用户ID、权限信息直接从Session里拿,代码里充斥着(Long)session.getAttribute("user"),既不安全,也让业务逻辑与Web容器耦合过紧。 - 对“超卖”问题无感知:优惠券秒杀、限量商品抢购是点评类系统的常见场景。很多项目的下单逻辑是:1)查询库存
stock > 0; 2)创建订单; 3)扣减库存update set stock=stock-1。在高并发下,多个线程同时查到库存为1,都认为自己能下单,最终会导致库存被扣成负数,这就是超卖。
2. 关键技术选型:为什么是它们?
面对这些问题,我们需要引入一些“外援”。选型的原则是:简单、有效、社区成熟。
持久层:MyBatis-Plus 优于 “裸” MyBatis
- “裸”MyBatis:需要手写大量简单CRUD的SQL和XML文件,对于毕业设计这种表结构不算复杂的项目,属于重复劳动。
- MyBatis-Plus:它是在MyBatis基础上的增强。最大好处是内置了通用Mapper和Service,像单表的增删改查,一两行代码就搞定。比如分页查询、逻辑删除这些常用功能,配置一下就能用。这能让我们把精力更集中在复杂的业务SQL和性能优化上,而不是基础的
insert into。对于快速构建和保持代码整洁,MP优势明显。
缓存: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. 生产环境避坑指南(进阶思考)
把系统跑起来只是第一步,要让它稳定运行,还得考虑更多。
- 缓存穿透防护:我们上面用了“空值缓存”,这能解决大部分问题。对于恶意攻击用不同ID轮询的情况,可以考虑使用布隆过滤器(Bloom Filter)在查询缓存前先做一层过滤,判断ID是否可能存在,如果布隆过滤器说“不存在”,那一定不存在,直接返回,保护了缓存和数据库。
- Token续期机制:JWT Token过期了怎么办?让用户重新登录体验很差。常见的做法是:在Token快过期时(比如在拦截器里发现剩余有效期小于10分钟),在返回的响应Header中塞入一个新的Token。前端检测到有新Token就替换旧的。这实现了“无感刷新”。
- 接口幂等性处理:对于点赞、下单这类操作,网络超时可能导致前端重复提交。我们的点赞用Redis Set的
SADD天然是幂等的(重复加同一用户返回0)。对于下单,可以在请求时带上一个唯一凭证(如UUID),服务器用Redis记录这个凭证是否已处理过,实现“一张票只验一次”。 - 分布式锁解决超卖:这是我们开头提到的大坑。用Redis的
setnx命令(或Redisson客户端)可以实现一个分布式锁。秒杀扣减库存时,先抢锁,抢到锁的线程执行“查询+判断+扣减”的整个流程,执行完释放锁。这样就能保证库存检查和高并发更新是串行化的,彻底解决超卖。这是面试中非常高频的问题。
写在最后
这次从“功能实现”到“并发设计”的重构过程,让我感觉像是给一个毛坯房做了一次精装修。不仅住着更舒服(性能好),结构也更稳固了(健壮性高)。对于毕业设计或者个人项目来说,在完成基本功能后,主动去思考并解决这些高并发、高可用的问题,是一个非常好的进阶路径。
最后留一个思考题:如果我们要把这个点评系统升级,加入“附近好评店铺推荐”的功能(LBS,基于地理位置的服务),架构上可以怎么做?是不是可以引入Redis的GEO数据类型来存储店铺坐标,实现快速的地理位置查询?又或者,当数据量极大时,是否要考虑用专门的时空数据库?技术的演进总是伴随着业务的需求,而我们的系统设计,也需要为这种演进留出空间。