在Java Web这个方向上,零散功能好做,完整项目难做。这几年我陆续带过不少人做Spring Boot项目,发现最常被选中的题目翻来覆去就是几个:零食销售商城、交友社区在线聊天、聊天室用户管理系统。放在一起看很有意思——一个是纯电商,一个是社交互动,一个直接就是IM场景,业务面相去甚远。但你真正动手拆一遍就会发现,它们底层几乎是同一套技术底座:Spring Boot搭骨架、MySQL管业务数据、Redis做缓存和在线状态、WebSocket处理实时消息,JWT负责身份认证。这篇文章就把这三个题目放在同一条技术路线上讲透,从商城项目的订单与库存设计,到交友社区的实时互动实现,再到聊天室的消息持久化和用户管理,每一步我都会讲清楚"为什么这么做"以及实际开发中容易踩的坑。无论你是准备毕业设计,还是想练手做一套能完整演示的Java项目,都可以按这套思路把其中一个做出来,而不是停留在Demo拼凑的水平。
1. 题目拆解:三个业务面如何共用一套Java技术底座
很多同学拿到这类题目,第一反应是"这不就是个增删改查"。这话一半对,一半不对。增删改查确实是骨架,但评阅老师或者面试官真正看的,是你在增删改查之上怎么处理业务约束、并发问题、实时通信和数据结构设计。三个题目各自的侧重点完全不同,我先把它拆开看。
零食销售商城属于典型的B2C电商,核心是商品、购物车、订单、库存这条交易链路。它的考核难点在于:库存不能超卖,订单状态要流转正确,购物车和订单要能对得上账。说白了就是"钱和货"不能乱。
交友社区在线聊天与互动平台侧重的是用户和用户之间的连接。注册登录、个人资料、好友列表,这些是基础;加分项在"在线聊天"和"互动"这两个词上,意味着你要处理实时消息推送、在线状态展示、一对一会话和公共频道的互动。
聊天室用户管理、交友大厅与消息系统构建,本质上是一个完整的IM后台。它有用户管理、交友大厅(也就是类似广场那样的大批量用户列表与上下线通知)、消息系统(单聊消息、群发消息、离线消息、已读未读)。它比交友社区更强调消息本身的生命周期管理和用户状态管理。
三者的关系往里收一收就是这样一张对比:
| 能力模块 | 零食商城 | 交友社区 | 聊天室 |
|---|---|---|---|
| 用户注册登录 | 需要 | 需要 | 需要 |
| 商品/资料管理 | 商品SKU管理 | 用户资料与标签 | 用户资料管理 |
| 购物车 | 核心功能 | 不需要 | 不需要 |
| 订单与库存 | 核心难点 | 不需要 | 不需要 |
| 好友/关注关系 | 不需要 | 需要 | 需要 |
| 在线状态 | 可选 | 核心体验 | 核心体验 |
| 实时聊天 | 可选(客服) | 核心功能 | 核心功能 |
| 消息持久化 | 不需要 | 需要 | 需要 |
看这张表就明白,三个题目不是割裂的,而是一条由简到繁的进阶路径。先做商城,你能把Spring Boot的项目分层、数据库事务、状态机设计练扎实;再做交友社区,你把WebSocket和实时交互补上;最后做聊天室,你再把用户管理、离线消息这些IM特有的设计补完整。三个都做完,Java后端的主流开发场景基本就都覆盖了。
技术选型上,我的建议是统一用Spring Boot 2.7.x或者3.x,持久层用MyBatis-Plus而不是JPA,数据库用MySQL 8.x,缓存和在线状态用Redis,实时通信用Spring WebSocket模块,权限控制用JWT + 拦截器。这套组合的好处是:资料多、上手快、出问题容易搜到解决方案。
1.1 评阅和验收真正看什么
这个话题我得单独拿出来说,因为太多人把力气花错地方。交上去一个Java项目,评阅老师看代码第一眼不是看功能多炫,而是看结构是否清晰。
首先是项目能不能一键跑起来。数据库脚本有没有、配置文件里的连接串对不对、启动时会不会报错,直接决定印象分。其次是分层是否干净,Controller、Service、Mapper、Entity、VO这些包是不是分得清楚,有没有把业务逻辑全堆在Controller里。再往下看,就是刚才说的那些难点有没有着落:库存扣减写没写并发控制,消息推送是轮询还是WebSocket,密码是不是明文存储,等等。
我见过一个交友社区项目,功能做得挺全,但所有SQL都写在Controller里,Service层形同虚设,数据库连接串还硬编码在Java文件里。这种项目就算界面再好看,也很难得到高分。所以这篇文章后面的内容,我会一边讲功能实现,一边强调工程规范,两者是绑在一起的。
2. 商城项目:库存、购物车与订单状态是真正拉开差距的地方
零食商城这类项目表面上功能很多:首页展示、商品搜索、分类筛选、购物车、下单、支付、订单列表。但实际上核心只有三件事:库存对不对、购物车和订单能不能衔接、订单状态是否在各种操作下保持一致。把这三件事做扎实,项目就立住了。
2.1 SKU和SPU:商品数据建模的第一步
不少第一次做商城的人把商品表设计成一张大宽表,字段包含商品名、价格、库存、分类、图片、描述。听起来省事,但实际上会出问题。零食这种商品有很强的规格属性——同一款辣条可能分原味和香辣味,同一款饮料分250ml和500ml,它们的价格和库存完全不同。
正确做法是拆开成商品SPU和SKU两层。SPU是"商品"这个概念,比如"某品牌香辣薯片";SKU是"具体可购买的规格",比如"香辣薯片-原味-90g"。SKU表里存价格、库存、规格参数,SPU表里存商品名称、主图、描述。这样购物车结算时,加进购物车的是SKU商品,下单扣减的是SKU的库存,价格也是一口价,不产生歧义。
我建议的商品相关表结构至少包含:
CREATE TABLE spu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category_id BIGINT, main_image VARCHAR(255), detail TEXT, status TINYINT DEFAULT 1, created_at DATETIME ); CREATE TABLE sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, spu_id BIGINT NOT NULL, spec_name VARCHAR(50), price DECIMAL(10, 2) NOT NULL, stock INT NOT NULL DEFAULT 0, image VARCHAR(255) ); CREATE TABLE sku_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT UNIQUE NOT NULL, stock INT NOT NULL );把库存单独拆一张表,不是闲得慌,而是为后续的并发扣减做准备。库存这种频繁更新的数据单独放,能减少锁的粒度。如果你把所有字段放一张表,每一次库存变更都会带上整行数据的写锁,性能上吃亏;独立出来以后,扣库存只影响这一小行。
2.2 超卖问题:为什么不能先在Java里if判断再扣库存
这是商城项目最经典的考差点,几乎必问。我先说错误写法:
// 错误示例:并发下会超卖 public boolean reduceStock(Long skuId, int count) { SkuStock stock = stockMapper.selectBySkuId(skuId); if (stock.getStock() >= count) { stock.setStock(stock.getStock() - count); return stockMapper.updateById(stock) > 0; } return false; }这段代码的逻辑看起来没有问题:先查库存,判断是否充足,再扣减。但两个用户同时下单时,A线程和B线程可能同时读到剩余库存是10,都判定为充足,然后都执行扣减,各自把库存改成9。结果是库存最终变成9,却卖出了两单。这就是典型的超卖。
解决办法不是加synchronized,也不是在应用层做复杂控制,而是让数据库自己来保证原子性。用一条带条件判断的Update就可以:
UPDATE sku_stock SET stock = stock - #{count} WHERE sku_id = #{skuId} AND stock >= #{count}这条SQL的执行效果是:更新时数据库会对命中行加锁,同时通过stock >= #{count}这个条件保证只有库存足够的时刻才允许扣减。如果影响行数为1,说明扣减成功;影响行数为0,说明库存不足或记录不存在。事务里同时要把订单创建和库存扣减放在一起,任何一个失败都要回滚。
实际项目中更复杂的做法是用Redis做预扣减,再异步同步到数据库,但那套方案涉及缓存一致性,对毕设和练手项目来说过重了。用SQL条件更新已经能解决核心问题,而且面试时讲起来头头是道。
2.3 购物车方案:从Session到Redis的结构取舍
购物车有两种主流做法:Session购物车和Redis购物车。Session方案在单机环境下最简单,缺点也明显:用户关掉浏览器购物车就丢了,服务器重启也丢。Redis方案就稳定得多,而且天然支持分布式场景。
我一般采用Redis的Hash结构来存购物车:
// key是carts:用户ID,field是skuId,value是数量 ValueOperations<String, Object> ops = redisTemplate.opsForValue(); // Hash操作示例 HashOperations<String, String, Integer> hashOps = redisTemplate.opsForHash(); hashOps.put("carts:" + userId, skuId.toString(), count);结账时,把购物车里的商品列表拿出来,重新查询SKU的实时价格和库存,而不是直接信任购物车里的价格。这个"重新查价"很关键,因为商品可能在你浏览到结账的间隙里调整了价格。随后执行整个下单流程:
- 创建订单主表记录,状态设为"待支付"
- 创建订单明细表,记录商品快照(名称、规格、单价、数量)
- 执行库存扣减SQL,失败则回滚
- 清除Redis购物车中已下单的商品
整个流程必须放在同一个事务方法里。你可以在方法上直接加@Transactional,让Spring帮我们管理事务边界。注意,扣库存和创建订单不能拆成两个接口让前端连续调用,否则中间断掉就会出现"有订单但没扣库存"的脏数据。
2.4 订单状态机与支付回调
订单状态不能随便乱跳,必须有一套明确的状态流转。商城项目我建议至少维护这几个状态:待支付、已支付、待发货、已完成、已取消。支付的实现上,真实对接支付网关需要商户号等资质,毕设阶段大家通常不满足条件,但我不建议做"点击按钮直接改状态"的做法,太容易在答辩时被问倒。
更好的方案是接入支付沙箱环境,也就是第三方支付平台提供的测试接口。模拟支付流程和真实支付几乎一致:下单后生成支付参数,前端跳转到沙箱支付页面,用户确认后支付平台回调你的后端接口,后端校验签名并更新订单状态。就算接口申请不下来,也建议在代码里预留一个PaymentService接口,提供沙箱实现和本地模拟实现两种模式,并且在文档里说明两种实现各自的场景。这样答辩时你能讲清楚真实支付对接的链路,而不是一句"我直接改状态"带过去。
订单超时取消可以用Spring自带的定时任务:
@Scheduled(fixedDelay = 60_000) public void cancelExpiredOrders() { List<Order> orders = orderMapper.selectExpiredPendingOrders(Duration.ofMinutes(30)); for (Order order : orders) { order.setStatus(OrderStatus.CANCELED); orderMapper.updateById(order); // 回补库存 stockMapper.increaseStock(order.getSkuId(), order.getQuantity()); } }定时任务在单机项目里够用,如果你以后想扩展成分布式,就需要引入分布式锁或者消息队列延迟消息。至少这个代码让评阅老师能看到你考虑了"订单超时"这个现实问题,而不是只做正向流程。
3. 交友社区:用WebSocket把"在线互动"从演示变成可用
交友社区与商城的最大区别是引入了实时性。用户登进交友大厅,要能看到谁在线;给别人发消息,对方要马上收到;大厅里有人上线,其他用户要能看到提示。这些交互如果都用HTTP请求去模拟,会非常别扭。
3.1 轮询为什么不行,WebSocket为什么合适
一个很自然的想法是前端每隔2秒请求一次接口,查询对方有没有新消息。这叫做HTTP轮询。短时间看它能工作,但有两个硬伤:一是浪费资源和带宽,明明大多数时候没有新消息,却要反复发送请求;二是消息延迟取决于轮询间隔,体验好不了。
WebSocket的思路完全不同。它通过一次HTTP握手建立长连接,之后服务器和客户端可以随时双向推送数据。建立连接后,服务器有消息了直接"推"给客户端,不需要客户端反复来问。我一直觉得这个类比最合适:HTTP像是你每隔几分钟给快递站打个电话问包裹到没到,WebSocket则是快递员到了直接按门铃。
Spring Boot对WebSocket的支持很完善,尤其推荐用STOMP子协议来定义消息的路由规则。配置一个消息代理很简单:
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("*") .withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic", "/queue"); registry.setApplicationDestinationPrefixes("/app"); } }这段配置的意思:前端通过/ws建立WebSocket连接;客户端发给服务端的消息路径以/app开头;服务端广播推送以/topic开头;点对点推送以/queue开头。发消息的Controller写法类似于写一个HTTP接口:
@MessageMapping("/chat.send") @SendTo("/topic/lobby") public ChatMessage sendToLobby(ChatMessage message) { return message; }前端往/app/chat.send发送消息,服务端处理后再广播给所有订阅了/topic/lobby的用户。加好友、点赞、上线通知,都可以走这套通道,只是消息体类型不同。
3.2 在线状态维护:ConcurrentHashMap与心跳机制
在线状态是交友社区里的关键数据。最常规的做法是在服务端维护一个在线用户列表。因为WebSocketSession本身就是有状态的对象,我们可以用一个全局Map把它管理起来:
public class OnlineUserRegistry { private static final ConcurrentHashMap<Long, WebSocketSession> USERS = new ConcurrentHashMap<>(); public static void add(Long userId, WebSocketSession session) { USERS.put(userId, session); } public static void remove(Long userId) { USERS.remove(userId); } public static WebSocketSession get(Long userId) { return USERS.get(userId); } public static boolean isOnline(Long userId) { return USERS.containsKey(userId); } public static List<Long> getAllOnlineIds() { return new ArrayList<>(USERS.keySet()); } }ConcurrentHashMap是线程安全的,这在多线程环境下很重要,WebSocket的连接建立和断开会发生在不同请求线程里,如果用普通HashMap,高并发下可能出现死循环或数据错乱。
但是有一个问题:客户端断网时,服务端不一定立刻感知到连接断开。TCP层面可能要等很久才能发现。所以不能只靠断开事件来维护在线状态,还需要心跳机制。客户端每隔一段时间发送一个Ping消息,服务端收到后更新时间戳,并定期扫描,把超过一定时间没心跳的用户强制下线。这里可以复用很多现成方案:前端用setInterval每隔30秒发一次心跳,后端用一个定时任务扫描最后活跃时间,超过90秒判定为掉线。这样在线列表的准确性就高多了。
3.3 单聊推送与消息接收的细节
单个用户之间的聊天,不能再用广播/topic,因为那会把消息发给大厅里的所有人。STOMP里点对点推送一般这样写:
@MessageMapping("/chat.sendToUser") public void sendToUser(ChatMessage message) { // 找到接收者的session WebSocketSession target = OnlineUserRegistry.get(message.getToUserId()); SimpMessagingTemplate messagingTemplate; messagingTemplate.convertAndSendToUser( message.getToUserId().toString(), "/queue/chat", message ); }convertAndSendToUser会自动把消息推送到接收者对应的WebSocket连接上,前提是接收者的会话标识(比如session中的用户ID)已经被正确解析。这又引出一个重要问题:WebSocket握手时怎么知道当前连接属于哪个用户?
方案是在握手阶段通过请求参数或者Header携带Token。我一般写一个HandshakeInterceptor,在beforeHandshake里解析Token,把用户ID放进attributes,后续在WebSocketSession里就能取到。这里要特别提醒:不要直接用用户ID作为连接路径参数,比如/ws/123,否则别人知道ID就能伪造。正确做法是传一个JWT,握手时校验。
聊天消息的实时性,到这里就基本解决了。但只做实时推送还不够,消息必须落库,不然刷新页面或者换个设备登录,历史消息就全没了,这就是下一章要讲的消息系统内容。
4. 聊天室:用户管理、好友关系与消息持久化的表设计
聊天室项目比交友社区更重"管理"二字:用户大量涌入,消息频繁产生,好友关系要维护,离线消息不能丢。这个部分的核心不在WebSocket,而在数据库设计。
4.1 用户体系:注册、密码加密与鉴权
用户表是聊天室的基础。字段至少包括用户ID、用户名、密码、昵称、头像、个性签名、角色、注册时间。密码的存储是安全问题,也是评阅老师容易盯上的点。绝对不允许明文存储,项目里用BCrypt加密,Spring Security框架自带的密码编码器就可以:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); // 注册时 String encoded = encoder.encode(rawPassword); // 登录校验时 boolean matched = encoder.matches(rawPassword, encoded);BCrypt的特点是即使相同密码每次加密结果都不同,并且计算较慢,这能让暴力破解的成本大幅提高。
鉴权方面,如果只想快速完成项目,可以用JWT加拦截器。登录成功后生成Token返回给前端,前端每次请求在Header里带上Authorization: Bearer <token>,服务端写一个拦截器统一校验。校验通过后把当前用户信息放进ThreadLocal,后续代码随时可取。代码结构大概是:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization").replace("Bearer ", ""); Long userId = JwtUtil.parseToken(token); if (userId == null) { response.setStatus(401); return false; } UserContext.set(userId); return true; } }不建议这类项目直接上一整套Spring Security,不是说它不好,而是配置复杂、学习曲线陡。除非你有精力把过滤器链和认证流程讲透,否则简单拦截器更能让你在项目文档里写出"每一行都是自己写的"。
4.2 好友关系表:关注加双向确认
交友大厅判断"是不是好友"需要一张明确的关系表。最简单的模型是好友申请模式:用户A向用户B发送申请,B同意后两人成为好友。相关表设计:
CREATE TABLE user_relation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '主动发起方', target_user_id BIGINT NOT NULL COMMENT '接收方', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已通过 2已拒绝 3已拉黑', created_at DATETIME, updated_at DATETIME, UNIQUE KEY uk_user_target (user_id, target_user_id) );查询两个人是否是好友,就需要查双向记录:(user_id, target_user_id)存在且status为1,或者反过来。如果数据量大,可以加冗余字段做优化,但在这个项目规模下,一条SQL就能解决。
好友列表的查询需要join用户表拿昵称和头像。这里有个经验:不要在关系表里冗余存储对方昵称,昵称会变,存了反而麻烦。昵称和头像应该只存在用户表,查询时join。消息记录则是另一个场景,我会在下文说到为什么那边要冗余昵称。
4.3 交友大厅的实现逻辑
交友大厅本质是一个可以筛选的在线用户列表。它的核心数据来自在线状态注册表,再结合用户资料表做展示。服务端提供一个接口,返回所有在线用户的ID列表,然后前端通过ID批量获取用户资料。这种方式减少重复查询,也更灵活。
比纯粹展示更进一步的是"上下线通知"。这需要WebSocket配合:有人上线时,服务端向大厅广播一条ONLINE事件;有人下线时,广播OFFLINE事件。前端收到事件后,增量更新大厅的用户列表,而不是整页刷新。这个交互是聊天室项目里最亮眼的功能之一,很值得花时间做细。具体操作就是在前面的OnlineUserRegistry的上线和下线逻辑里,通过SimpMessagingTemplate向/topic/lobby推送事件消息。
4.4 消息持久化:离线消息与已读未读
消息表设计是整个聊天室项目的压舱石。我见过很多方案,最后稳定下来的是这一张:
CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user_id BIGINT NOT NULL, to_user_id BIGINT NOT NULL COMMENT '单聊为对方ID,群聊/大厅为群组ID', content TEXT NOT NULL, message_type TINYINT NOT NULL DEFAULT 1 COMMENT '1文本 2图片 3系统', read_flag TINYINT NOT NULL DEFAULT 0 COMMENT '0未读 1已读', channel VARCHAR(20) DEFAULT 'single' COMMENT 'single/group/lobby', create_time DATETIME NOT NULL, INDEX idx_to_user_time (to_user_id, create_time), INDEX idx_from_user_time (from_user_id, create_time) );这里我要重点讲一个反直觉的设计:消息表里冗余存储发送者昵称和发送者头像。这不是为了省事,而是因为消息是历史快照,用户改了昵称换掉头像后,往日的聊天记录不应该跟着变。如果你查询时才去join用户表,老历史消息显示的就是最新的昵称,这是不合理的,实际上所有IM产品都是快照式存储。
读取对话的接口逻辑是:查聊天记录时,先按会话双方取出最近的消息,再统计未读数。简单实现可以这样:
-- 最近的历史消息 SELECT * FROM chat_message WHERE (from_user_id = #{userId} AND to_user_id = #{otherId}) OR (from_user_id = #{otherId} AND to_user_id = #{userId}) ORDER BY create_time DESC LIMIT 50;离线消息的实现思路:用户上线后,客户端发送上次活跃时间,服务端查询该时间段内发给这个用户且未读的消息。
List<ChatMessage> offlineMessages = messageMapper.selectUnreadMessages( userId, lastOnlineTime );已读未读的更新有三种策略:每条消息单独标记已读(简单但消息多时慢);按会话维度批量标记已读(推荐);或者维护一个lastReadTime字段,之后把早于该时间的消息都视为已读(复杂但数据量最优雅)。对于练手项目,批量标记已经足够:
UPDATE chat_message SET read_flag = 1 WHERE to_user_id = #{userId} AND from_user_id = #{otherId} AND read_flag = 0消息量上来以后,需要关注查询性能。别小看这张表,聊天室项目里它增长最快。给to_user_id + create_time建联合索引是最基本的。索引加的合理,查询翻页才不会随着数据量增长而明显变慢。
5. 从"能跑"到"能讲":工程化规范与验收演示的实战细节
很多项目功能做出来了,但代码没法看,答辩也讲不圆。这章我讲一些能让项目整体提升一个档次的工程化细节。它们不增加任何一个新功能,却能大幅提升评价。
5.1 包结构、统一返回体与全局异常处理
包结构一定要清晰,这是代码的第一印象。我见过一个项目把所有类都丢在一个包下面,两百多个文件,评阅老师光找入口就要半天。推荐的包结构是:
com.example.project ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── vo ├── config ├── common │ ├── Result.java │ ├── ResultCode.java │ └── GlobalExceptionHandler.java └── utilsController只负责接收参数和返回结果,业务逻辑全部下沉到Service接口及其实现类,Mapper只是数据访问层。前端拿到的数据用VO封装,不要直接把Entity暴露给前端。比如用户表里有密码字段和盐值,直接返回Entity等于把密码也交给前端了。DTO用于接收前端参数,也能避免前端传来的多余字段污染实体。
统一返回体和全局异常处理是工程化的标配。返回体设计成code + message + data三件套:
public class Result<T> { private int code; private String message; private T data; }加上一个GlobalExceptionHandler,把业务异常和系统异常分开处理,前端永远能拿到格式一致的JSON。否则像空指针这种未捕获异常,Spring返回一大段堆栈信息到前端,既不安全又难看。做这个改造的成本很低,但代码质感提升非常明显。
5.2 配置环境分离与数据库脚本随项目交付
application.yml要把开发环境、生产环境的配置分开。实际项目里数据库连接串、Redis地址、日志级别都要按环境区分。就算你的项目只在本地跑,分环境配置也能让你在演示时少很多麻烦。格式大致是这样:
spring: datasource: url: jdbc:mysql://localhost:3306/snack_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: localhost port: 6379最重要的是数据库初始化脚本init.sql要随项目一起交付,并包含初始数据。我评审过项目,最怕的就是拿到手没有SQL脚本,或者脚本只有建表语句没有基础数据,导致项目启动后页面一片空白。评阅老师拿到项目第一件事往往是执行SQL脚本、启动后端、打开前端,这三步任何一步卡住,整体评分都会大受影响。所以init.sql里至少要有内置管理员账号(比如admin/123456,密码是BCrypt加密后的值)、几件商品、几个测试用户,点开首页就能看到内容。
5.3 答辩和验收时最容易被追问的三个技术问题
根据我带项目的经验,评阅和面试官追问最多的问题集中在三个点上,你最好提前把答案准备成一段能讲两三分钟的话。
第一个:库存扣减怎么防止超卖。正确的回答链路是先说"不能用先查再改的写法",再说用SQL条件更新的原子操作,最后补一句"高性能场景可以上Redis预扣减,但需要考虑Redis和数据库的一致性"。这个问题很能区分项目是背出来的还是自己写出来的。
第二个:WebSocket断线重连和消息丢失如何处理。很多人做到消息实时推送就以为结束了,追问才发现没有考虑网络抖动。你应该说明:前端监听关闭事件后自动重连,重连成功后重新拉取离线消息,同时服务端通过心跳判断连接是否存活。把这些说清楚,评阅老师就知道你完整考虑过IM场景的网络问题。
第三个:在线用户列表在分布式下怎么办。ConcurrentHashMap的实现在单机没问题,但一旦部署多台服务器,每台机器只有本机的在线用户。你可以先坦率说明这个是单体项目,在线状态存在单机内存中,然后指出扩展方向:用Redis的统一在线状态存储,或者用Redis的Pub/Sub做跨节点广播,配合消息队列处理大规模消息。能答出这一步,是很明显的加分项。
5.4 演示路径设计:先跑通主干流程再展示亮点
最后说演示。我见过不少人在答辩现场翻车,原因都出在没有设计演示脚本。项目演示应该有一条完整的主线,沿着这条线一步步走下去,不要跳来跳去。以交友社区和聊天室为例,我建议的演示顺序是:
- 启动后端和前端,清理掉脏数据,保证首页干净
- 注册一个新用户,强调密码是加密存储的
- 登录,进入交友大厅,看到在线用户列表
- 用两个不同浏览器分别登录两个账号,演示加好友、单聊、大厅广播
- 关掉一个浏览器表示断线,重新打开,演示离线消息拉取和已读未读
- 最后再打开数据库控制台或Redis,展示在线状态和聊天记录确实落库了
这串流程走下来,不需要任何花哨的表演,就已经把项目里最难的部分全部展示出来了。不要演示到一半临时去查SQL、现找配置项、重开服务器,那种操作会让前面所有的好印象都打折。
我个人在带项目时始终相信一句话:一个完整的垂直业务线,胜过十个半吊子的功能平铺。无论是零食商城还是聊天室,先确保"注册—登录—核心业务—数据落库"这条线畅通无阻,再往上面加东西。很多同学一开始就铺开做十几个页面,结果没有一个功能能从头走到尾,最后验收时到处是漏洞。做项目的顺序应当是先窄后宽,先把一条主链路打通、跑稳、讲清楚,再去扩展其他模块。这套思路放到这三个题目上都成立,拿任何一个做深做透,都足够撑起一次漂亮的验收。