简介:面向Java Web初学者与课程实践者的个人微博应用完整项目包,对应Web原理及应用实验场景,围绕会员管理、个人中心与消息发布管理三个核心模块,搭建可运行的小型社交平台。包内共71个文件、约3.33MB,以java源文件与编译后的class为主,配合jsp页面、xml配置、css样式与图片资源,另有MySQL数据库文件及少量jar依赖,覆盖前端界面、后端服务与数据存储的完整链路。已有224人学习下载。读者可据此梳理Servlet与JSP的请求处理流程、用户注册登录与权限校验思路、RESTful接口与CRUD设计方式,以及微博动态的发布、展示与检索实现,也可借助工程目录理解Eclipse项目的结构组织与配置文件作用,适合作为课程实验参考、二次开发起点或面试前的实战复盘材料。
1. 从一条动态的发布链路说起:个人微博应用到底能干什么
刚学完 Servlet 和 JSP 的人,最常见的状态是:每个知识点都懂,但让你从零搭一个能注册、能登录、能发内容、能看列表的站,手就停了。这份「Web原理及应用:个人微博应用」就是用来补这个缺口的——它不是一个炫技项目,而是把 HTTP 请求、会话管理、请求转发与重定向、JSP 作用域、JDBC 增删改查这几件事串成一条完整的个人微博应用链路,前端是原生 HTML/CSS/JS 加 JSP 渲染,后端是 Servlet + Service + DAO 三层,数据落在 MySQL 里,跑在 Tomcat 上。
它能干的事很具体:注册登录、发一条短动态、按时间倒序刷自己的主页、点进别人的主页看他的动态、关注和取关、删掉自己发错的那条、给动态写一条评论。适合两类人:一类是要交课程设计或者做课程验收,需要一个结构说得清、代码看得懂、答辩讲得出的完整工程;另一类是自己写过几个 Servlet demo,但从来没处理过登录态、分页、中文乱码这些真实问题,想找个能跑通的参照物。这类不套框架的项目,反而是理解 Web 原理最直接的路子。
2. 技术栈与工程骨架:一个 /post 请求从浏览器到 MySQL 的四层链路
选型这块先说清楚,因为很多人拿到项目第一反应是「怎么没用 Spring Boot」。这个项目的定位就是课程里的 Web 原理实践,用 Servlet 3.1 + JSP 2.3 + JDBC + MySQL 5.7/8.0 + Tomcat 8.5/9,前端不引入任何构建工具,CSS 和 JS 直接script标签引。好处是所有东西都裸露在外面:请求怎么进来、参数怎么取、会话存在哪、页面怎么转发,全都没有容器帮你藏起来。
2.1 不用框架的选择:Servlet + JSP + JDBC 各自负责什么
Servlet 负责接收 HTTP 请求、取参数、做基本校验、决定是转发到 JSP 还是重定向;JSP 只负责渲染,尽量不写 Java 业务代码,取数据靠 request/session 作用域里现成的属性;Service 层做业务编排,比如「发动态前先检查内容长度和用户状态」,以及开事务;DAO 层里全是 SQL,一条 SQL 一个方法,不掺业务判断;Model 是和表字段一一对应的 POJO。
这个分层要说清边界,否则写着写着就变成「Servlet 里直接调 DAO 再拼 HTML」,改一个字段要翻五个文件。我自己的习惯是:Servlet 里不允许出现Connection、PreparedStatement,DAO 里不允许出现HttpServletRequest。这两条守住,后面加功能才有地方下手。
各层职责对照:
| 层 | 典型类 | 允许做的事 | 禁止做的事 |
|---|---|---|---|
| controller | PostServlet | 取参、校验、调 service、转发/重定向 | 写 SQL、开连接 |
| service | PostService | 业务规则、事务边界 | 碰 request/response |
| dao | PostDao | 拼 SQL、映射 ResultSet | 判断登录态 |
| model | Post、User | 承载字段 | 写业务方法 |
2.2 目录结构与包划分:controller / service / dao / model 四层怎么切
工程按 Maven 的标准 webapp 结构组织,即使你手动建 Dynamic Web Project,也建议照着这个结构摆,后面打包、换 IDE 都不折腾。
weibo-web/ ├── src/main/java/com/example/weibo/ │ ├── controller/ # Servlet:LoginServlet、PostServlet、ProfileServlet、FollowServlet │ ├── service/ # 业务编排:UserService、PostService、FollowService │ ├── dao/ # SQL 集中地:UserDao、PostDao、FollowDao、CommentDao │ ├── model/ # 实体:User、Post、Comment、PageResult │ ├── filter/ # EncodingFilter、LoginFilter │ └── util/ # JdbcUtils、PasswordUtil、DateUtil ├── src/main/resources/ │ ├── db.properties # 数据库连接参数,不打进 jar,只放 classpath │ └── schema.sql # 建表 + 初始化数据 └── src/main/webapp/ ├── WEB-INF/ │ ├── views/ # 所有 jsp 放这里,外部不能直接访问 │ └── web.xml ├── static/css|js|img └── index.jsp # 唯一的入口跳转页JSP 放WEB-INF/views是关键一步。放在外面的话,用户直接敲/views/post-list.jsp就能绕过 Servlet 拿到一个没有数据的空页面,甚至看到页面里的调试信息。放进去之后只有服务端RequestDispatcher.forward()能到达,访问路径和渲染逻辑就锁死了。
2.3 请求注册与参数接收:web.xml 和 @WebServlet 两条路
Servlet 注册有两条路:web.xml里配<servlet>+<servlet-mapping>,或者在类上打@WebServlet注解。项目里两种都能看到,注解的写法更省事,但web.xml更适合需要统一调整顺序和初始化参数的场景。下面这个是发布动态的核心 Servlet:
@WebServlet(name = "postServlet", urlPatterns = "/post") public class PostServlet extends HttpServlet { // Service 无状态,可以复用;不要在这里存和请求相关的成员变量 private final PostService postService = new PostService(); private final FollowService followService = new FollowService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 列表页:?page=1&size=10,page 从 1 开始,越界时回退到 1 int page = parsePage(req.getParameter("page")); Long userId = (Long) req.getSession().getAttribute("uid"); req.setAttribute("pageResult", postService.timeline(userId, page, 10)); req.getRequestDispatcher("/WEB-INF/views/post-list.jsp").forward(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { Long userId = (Long) req.getSession().getAttribute("uid"); String content = req.getParameter("content"); String error = postService.publish(userId, content); if (error != null) { // 校验失败:用 session 传一次性错误提示,重定向回列表 req.getSession().setAttribute("flash", error); } // PRG:发布完重定向,刷新页面不会重复插入 resp.sendRedirect(req.getContextPath() + "/post"); } private int parsePage(String raw) { try { int p = Integer.parseInt(raw); return p < 1 ? 1 : p; } catch (Exception e) { return 1; } } }urlPatterns = "/post"是精确匹配,别图省事写成/*,那会把静态资源和 JSP 全部拦下来,最后 CSS 加载不出来、图片 404,排查半天以为是路径问题。doGet走转发是因为要带数据渲染 JSP;doPost走重定向是为了 PRG(Post-Redirect-Get),避免浏览器刷新触发重复提交。parsePage里对空值和负数做了兜底,分页参数是最容易被手动改坏的地方。
还有一点:Servlet 默认是单实例多线程,postService这种无状态对象做成员变量没问题,但千万别把Connection、User这类跟单次请求绑定的东西挂到成员变量上,否则两个用户同时发动态就会串数据,这种 bug 在本地单人测试时永远复现不出来。
3. 数据模型与持久层:四张表撑起发布、关注和时间线
微博类应用看着功能多,剥掉界面其实就四张表:用户、动态、关注关系、评论。表设计得好不好,直接决定后面时间线查询是一条 SQL 还是三次嵌套循环。
3.1 表结构设计:user / post / follow / comment 的字段与索引
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL COMMENT '登录名,唯一', `password_hash` CHAR(64) NOT NULL COMMENT 'SHA-256 加盐后的十六进制串', `nickname` VARCHAR(32) NOT NULL COMMENT '展示名', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像相对路径', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `post` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `content` VARCHAR(500) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '0 正常 1 已删', PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `follow` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `follower_id` BIGINT NOT NULL COMMENT '关注发起人', `followee_id` BIGINT NOT NULL COMMENT '被关注人', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_pair` (`follower_id`, `followee_id`), KEY `idx_followee` (`followee_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `comment` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `post_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `content` VARCHAR(200) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_post` (`post_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段值得单独说。password_hash用CHAR(64)存 SHA-256 十六进制串,注册时hash = sha256(salt + password),盐存不存另说,但一定不要明文入库——这是课程项目最容易被挑的毛病。post.deleted用逻辑删除而不是DELETE,因为删除后评论和点赞的关联数据还在,物理删除会留一堆孤儿行。
follow上的uk_pair唯一索引是防重复关注的关键,比如用户狂点关注按钮,两次请求同时到达,靠应用层先查后插是挡不住的,数据库唯一约束才是最后一道闸。post的联合索引(user_id, created_at)对应「查某人主页按时间倒序」,顺序不能反,反过来对时间线查询基本没用。
3.2 JDBC 工具类与 DAO 基类:连接、释放、事务三件事
JDBC 直连最容易翻车的不是 SQL 写错,是连接泄漏:本地跑一天没事,部署到测试机两天后 Tomcat 报Too many connections。工具类就干三件事——拿连接、还连接、包一层事务。
public final class JdbcUtils { private static final String URL; private static final String USER; private static final String PASSWORD; static { Properties p = new Properties(); try (InputStream in = JdbcUtils.class.getClassLoader() .getResourceAsStream("db.properties")) { p.load(in); URL = p.getProperty("jdbc.url"); USER = p.getProperty("jdbc.user"); PASSWORD = p.getProperty("jdbc.password"); Class.forName(p.getProperty("jdbc.driver")); } catch (Exception e) { // 静态块里失败就直接炸,别拖到第一次查询才报错 throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } /** 在同一个连接上跑一段逻辑,异常回滚,正常提交,最后一定归还 */ public static <T> T inTransaction(SqlFunction<T> work) { Connection conn = null; try { conn = getConnection(); conn.setAutoCommit(false); T result = work.apply(conn); conn.commit(); return result; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignore) { } } throw new RuntimeException(e); } finally { if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignore) { } } } } @FunctionalInterface public interface SqlFunction<T> { T apply(Connection conn) throws Exception; } }db.properties里通常这么写:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/weibo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.user=root jdbc.password=你的密码inTransaction的入参是Connection,意味着 DAO 方法有两种签名:一种自己拿连接(只读查询),一种接收外部连接(参与事务)。发布动态要同时写post表、可能还要更新用户动态计数,这种就必须用后者,否则两个 DAO 各拿一条连接,事务根本管不住。finally里先把autoCommit恢复再close,是因为如果用连接池,还回去的连接会带着上一次的状态,下一个拿到它的人就莫名处于事务里。
至于连接池,项目里可以直接DriverManager,但稍微认真一点我会换成 Druid 或 HikariCP,理由很朴素:DriverManager每次查询都新建 TCP 连接,一次时间线请求要查动态、查用户、查关注状态,三次握手就够呛,压测一轮下来延迟全在连接建立上。换池子只改getConnection()一个方法,DAO 一行不动。
3.3 时间线 SQL:把「我关注的 + 我自己的」按时间合并
个人主页好办,WHERE user_id = ?走索引就完了。麻烦的是首页时间线:要显示「我自己发的 + 我关注的人发的」,按时间倒序,还要分页。
SELECT p.id, p.content, p.created_at, u.nickname, u.avatar FROM post p JOIN user u ON u.id = p.user_id WHERE p.deleted = 0 AND (p.user_id = ? OR p.user_id IN (SELECT followee_id FROM follow WHERE follower_id = ?)) ORDER BY p.created_at DESC, p.id DESC LIMIT ? OFFSET ?ORDER BY created_at DESC, p.id DESC里的第二个排序字段不是多余:DATETIME精确到秒,批量插入的测试数据时间戳完全相同,只按时间排,翻页时会出现同一条记录在两页都出现或者干脆消失的玄学现象,加主键兜底就能稳定。LIMIT ? OFFSET ?在数据量小的时候没感觉,几千条以后越翻越慢,因为 OFFSET 是先扫再丢。真要优化,改成游标分页——记住上一页最后一条的created_at和id,下次查WHERE (created_at, id) < (?, ?),代价是不能跳页,只能「加载更多」。
分页结果用PageResult<T>装,字段就四个:list、page、size、total。total单独一条SELECT COUNT(*),别在列表 SQL 里用SQL_CALC_FOUND_ROWS,MySQL 8 已经废弃了,而且它在大表上比两条 SQL 还慢。DAO 里映射 ResultSet 时,created_at用rs.getTimestamp("created_at").toLocalDateTime(),别用getDate(),那玩意会把时分秒吞掉,页面上所有动态都显示成同一天。
4. 前端与接口联调:JSP 首屏渲染加原生 JS 异步发布
前端这块没有框架,全靠 JSP 拼首屏 + 原生fetch做局部刷新。听上去土,但好处是每一行都能对应到具体的 HTTP 行为,调起来心里有底。
4.1 页面拆分:include 指令拼出公共头部和侧栏
页面结构就三个:登录注册页、时间线列表页、个人主页。头部导航、侧边栏、底部脚本在每个页面都重复,用 JSP 的静态包含拆出去:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ include file="/WEB-INF/views/common/header.jspf" %> <main class="timeline"> <form id="publishForm" class="publish-box" method="post" action="${pageContext.request.contextPath}/post"> <textarea name="content" maxlength="500" placeholder="说点什么"></textarea> <button type="submit">发布</button> </form> <c:forEach items="${pageResult.list}" var="post"> <article class="post-item">// ctx 由 JSP 注入:window.APP_CTX = '${pageContext.request.contextPath}' const form = document.getElementById('publishForm'); const box = form.querySelector('textarea'); form.addEventListener('submit', async (e) => { const content = box.value.trim(); if (!content) { alert('内容不能为空'); e.preventDefault(); return; } // 支持异步才拦截,否则让浏览器走原生提交 if (!window.fetch) return; e.preventDefault(); const btn = form.querySelector('button'); btn.disabled = true; // 防连点重复发布 try { const resp = await fetch(window.APP_CTX + '/post', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: new URLSearchParams({ content }), credentials: 'same-origin' // 带上 JSESSIONID,否则后端拿不到登录态 }); if (resp.redirected || resp.ok) { location.reload(); // 简单起见整页刷新,保证分页和总数一致 } else { alert('发布失败,请稍后重试'); } } catch (err) { alert('网络异常'); } finally { btn.disabled = false; } });Content-Type必须是application/x-www-form-urlencoded,因为后端req.getParameter("content")只认这个格式和multipart/form-data,你发application/json过去,参数全是 null,还查不出原因。credentials: 'same-origin'也别省,同源请求默认就会带 cookie,但一旦你以后把前端拆出去跨域,不加这行 Session 就断了。
btn.disabled那个细节值得单独提:从点击到响应回来有几百毫秒,用户手快连点两下就是两条一模一样的动态。前端禁用按钮是第一层,后端唯一约束或者短时间内的内容去重是第二层,只做一层都不够。
4.3 登录态与权限:Session + Filter 的两道闸
登录成功后往 session 里塞uid和nickname,之后每个需要登录的接口都从 session 取用户,绝不信任前端传上来的userId——哪怕参数名藏得再深。
@WebFilter(urlPatterns = {"/post", "/profile", "/follow", "/comment"}) public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; Object uid = req.getSession().getAttribute("uid"); if (uid != null) { chain.doFilter(request, response); return; } String target = req.getRequestURI(); String query = req.getQueryString(); String back = URLEncoder.encode(query == null ? target : target + "?" + query, "UTF-8"); resp.sendRedirect(req.getContextPath() + "/login?redirect=" + back); } }过滤器只拦写操作和需要身份的页面,登录页、注册页、静态资源不在urlPatterns里,否则会出现「登录页自己把自己重定向到登录页」的死循环。redirect参数是为了登录完能回到原来那页,URLEncoder编码不能少,?id=3&tab=post里的&不编码,重定向后参数会被截断成两个。
过滤器顺序也要注意:编码过滤器必须排在登录过滤器前面,否则重定向地址里的中文昵称会变乱码。用注解注册的多个 Filter,执行顺序按类名字母序,靠不住,涉及顺序的就在web.xml里显式写<filter-mapping>,先后一目了然。
5. 常见问题排查:本地跑不起来的 5 个坑
这个项目本身不复杂,但环境、编码、事务这三类问题能让它在你机器上「明明代码一样就是跑不起来」。下面五条是我见过的最高频的。
5.1 环境与依赖类
坑一:页面能开,一发动态就报ClassNotFoundException: com.mysql.cj.jdbc.Driver。
现象是首页正常,提交表单后 500,日志里一长串找不到驱动类。原因通常是驱动 jar 只在 IDE 的 Build Path 里加了,没放进WEB-INF/lib——IDE 编译时能找到,部署到独立的 Tomcat 时,类加载器只认WEB-INF/lib和 Tomcat 的lib,于是运行时找不到。还有一种情况是版本对不上:MySQL 8.x 的驱动类名是com.mysql.cj.jdbc.Driver,还写老的com.mysql.jdbc.Driver会先警告再报错。解决办法两步:把mysql-connector-java的 jar 复制到src/main/webapp/WEB-INF/lib下,db.properties里写新类名,重新部署再试。
坑二:控制台刷The server time zone value '...' is unrecognized。
现象是所有数据库操作全部失败,异常信息里带时区字样。原因是驱动 8.x 默认要拿到服务端时区,而 MySQL 返回的时区标识 Java 不认。改连接串就行,在 URL 后面补serverTimezone=Asia/Shanghai,同时顺手把useSSL=false加上,本地没开 SSL 的话每次连接都会刷一条警告,日志里全是噪音,真出问题的时候反而看不见。
5.2 数据与编码类
坑三:发布的内容是中文,存进数据库变成问号或者乱码。
现象分两种:页面上直接显示????,或者显示成测试这样的串。前者说明数据库连接或列字符集不是utf8mb4,后者说明某一段用了 ISO-8859-1 解码。排查顺序固定:先看建表语句的DEFAULT CHARSET=utf8mb4,再看连接串的characterEncoding=utf8,再看EncodingFilter里request.setCharacterEncoding("UTF-8")是否在第一次getParameter之前执行——只要先取过参数,后面再设编码就无效了,这是 Servlet 规范定死的。解决办法是把编码过滤器配在web.xml最前面,JSP 里有pageEncoding="UTF-8",响应头也设resp.setContentType("text/html;charset=UTF-8")。
提示:
getParameter之后设编码无效这一条,是中文乱码排查里最容易走弯路的地方,先看过滤器顺序再看别处。
坑四:新增的数据列表里查不到,重启后又出现了。
现象很典型:发完动态页面刷新看不到,但用客户端连数据库能查到新行。原因是 Service 里开了setAutoCommit(false),某条分支没走到commit就返回了,或者事务方法里 catch 住异常吞掉,连接被归还但事务没提交,MySQL 在连接断开时回滚。解决方式是所有事务统一走JdbcUtils.inTransaction,不在业务方法里手写 commit/rollback;DAO 里用 try-with-resources 管PreparedStatement和ResultSet,把Connection的关闭权交给事务方法,谁开谁关。
5.3 逻辑与并发类
坑五:刷新页面就多一条一样的动态。
现象是发布成功后地址栏还停在 POST 的地址上,按 F5 浏览器弹「确认重新提交表单」,点确定就再插一条。原因是doPost里直接forward到列表页,浏览器记住的最后一个请求还是 POST。解决办法就是前面代码里的 PRG:处理完sendRedirect到doGet的地址。如果业务上不能容忍任何重复,再加一次性 token——进页面时生成 UUID 塞 session,表单带隐藏域,提交时比对并立刻从 session 移除。
还有一类不是重复提交,是url-pattern写错导致的 404:静态资源全挂在/static/下,如果某个 Servlet 映射成了/*,请求先被它接走,CSS 返回的是一段 HTML,浏览器控制台报 MIME 类型不对。看到样式丢了先去看web.xml和注解里的urlPatterns,比翻 CSS 快得多。
6. 二次开发:把单机微博改成带关注流的小型社交应用
拿到这个工程之后,最值钱的改造方向不是换框架,而是把「单表增删改查」补成「有真实数据关系」的功能。下面这几个改造按难度排,前两个改动小、见效快,适合先热身:
| 改造点 | 涉及文件 | 关键动作 |
|---|---|---|
| 动态配图 | post加image_url、PostServlet加 multipart 分支 | 用@MultipartConfig接管上传,文件存到 webapp 外,用 Servlet 输出流读 |
| 点赞 | 新建like表,唯一索引(post_id, user_id) | 点赞/取消用INSERT IGNORE和DELETE,回传最新计数 |
| 时间线游标分页 | PostDao.timeline | 把LIMIT OFFSET换成(created_at, id) < (?, ?) |
| 换连接池 | JdbcUtils.getConnection | 引入 Druid,初始化DataSource,其余代码不动 |
我拿「点赞」举例,因为它把唯一约束、幂等、计数回传三件事一次性讲清楚了:
// LikeServlet#doPost:点赞与取消都走同一个接口,靠 action 参数区分 @WebServlet("/like") public class LikeServlet extends HttpServlet { private final LikeService likeService = new LikeService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { long postId = Long.parseLong(req.getParameter("postId")); long uid = (Long) req.getSession().getAttribute("uid"); boolean like = "1".equals(req.getParameter("action")); // service 内部对同一对 (postId, uid) 做幂等:重复点赞不报错、计数不变 int count = likeService.toggle(postId, uid, like); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"count\":" + count + ",\"liked\":" + like + "}"); } }toggle里点赞用INSERT IGNORE INTO user_like(post_id, user_id) VALUES(?,?),取消用DELETE,两条语句都靠唯一索引保证幂等,用户狂点也不会把计数点炸。计数不建议每次COUNT(*),动态量大了以后这个查询会拖垮列表页,可以在post表加一列like_count,在同一事务里UPDATE ... SET like_count = like_count + 1,读的时候直接取。
验证方式也别靠肉眼。改造完至少跑三遍:第一遍用两个浏览器(或者一个正常窗口一个隐身窗口)互相点赞,确认计数正确;第二遍在点赞接口上手动连点十次,确认最终计数是 1;第三遍把数据库连接数调到很小,看有没有连接泄漏。这三遍走完,功能基本就站得住了。
从那以后我每次动这类项目的持久层,都会先把「一个请求到底开了几条连接、每条在哪关的」在纸上画一遍,再动手写代码——早年有一次上线前没画,DAO 里嵌套调用各开各的连接,压测十分钟数据库就满了,那个下午的后悔药我到现在还记得味道。希望这些内容能帮你少走一段弯路。
本文还有配套的精品资源,点击获取