news 2026/10/30 10:26:34

Java Web个人微博项目:Servlet+JSP、游标分页与点赞事务拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java Web个人微博项目:Servlet+JSP、游标分页与点赞事务拆解

简介:面向课程实验与 Java Web 入门者的个人微博应用工程,围绕 Web 原理展开会员管理、个人中心、消息发布管理等核心模块,适合用来理解浏览器与服务器交互、会话与权限控制、动态页面渲染及数据持久化的基本流程。压缩包共 71 个文件,约 3.33MB,以 22 个 Java 源码、24 个编译类、5 个 JSP 页面和 3 个 XML 配置为主,另含 jar 依赖、CSS 样式及数据库文件,整体为 Eclipse Web 项目结构,导入后便于对照源码与页面调试。已有 224 人学习。工程把注册登录、个人信息展示、微博发布与列表查询串成可运行示例,内置数据库文件可减少环境配置成本,适合作为课程设计、实验报告或同类社交应用原型的参考底稿,也便于在此基础上扩展前端交互与后端接口。

1. 个人微博应用先看效果:一条微博从发布到首页要跨过几道坎

不少人做 Web 原理及应用这门课的期末项目,选的就是个人微博应用,最后交上去的样子却都差不多:五个 JSP 页面,两张表,增删改查跑通,截图一贴就算完。真正到答辩现场,只要被问一句「你刷新首页的时候,凭什么知道哪几条我已经点过赞」,多数人就答不上来了。

这个项目的价值不在界面上,而在于它把 Java Web 那条老链路老老实实走了一遍:HTTP 请求进 Tomcat,过滤器判断有没有登录,Servlet 接参数并校验,Service 管事务,DAO 拼 SQL 查数据,再把结果交给 JSP 回显。中间夹着 Session 生命周期、游标分页、关注关系查询、点赞去重这些容易被跳过、又恰恰是面试常问的点。它适合两类人:一类是要交课程设计,需要一套每一层都能讲清楚的骨架;另一类是写惯了前端、想回头搞清楚请求底层发生了什么。下面我按「先能跑、再能读懂、最后能改」的顺序拆。

2. 技术选型与工程骨架:Servlet + JSP 这套老组合为什么还值得跑一遍

2.1 选型判断:课程项目该用哪套组合

个人微博应用这种体量,功能清单基本固定:注册登录、发微博(带图可选)、首页时间线、关注与粉丝、点赞、评论、个人主页。差别只在用什么把它实现出来。市面上的做法大致可以归成三类,我按自己的经验列成表,方便你直接对号入座。

方案技术组合适合场景主要问题
原生 Servlet + JSPTomcat 9 + Servlet 4.0 + JSP/JSTL + JDBC课程设计、要讲清 Web 原理配置多,样板代码多
Spring Boot + ThymeleafSpring Boot 2.7 + MyBatis + Thymeleaf想快速出效果、后续要扩展框架把细节藏得太深,讲原理时说不清
前后端分离Spring Boot + Vue/React想顺便练前端工程化两套工程、跨域和鉴权都要自己处理

如果目标是「Web 原理及应用」这门课本身,我一般会选第一套。原因很直接:Servlet 的生命周期、Filter 的执行顺序、Session 的存在形式、JSP 的编译时机,这些东西在 Spring Boot 里被一层层封装掉了,答辩时你说「请求进了 Controller」,老师再追问一句「Controller 之前发生了什么」,就卡住了。而原生这套,从web.xml到doGet,每一步都能在代码里指出具体位置。

需要说明的是,选原生不代表要拒绝现代工具。Maven 管依赖、HikariCP 管连接池、JSTL 管页面渲染、Bootstrap 管样式,这些都能直接用。真正的分界线只有一条:业务代码是不是你自己从 Servlet 一路写到 JDBC 的。是的话,原理就吃透了。

运行环境我一般固定成 JDK 8 或 JDK 11 + Tomcat 9 + MySQL 8.0。这里有个容易翻车的地方:Tomcat 10 开始把javax.servlet全换成了jakarta.servlet,网上抄来的代码import javax.servlet.http.HttpServlet会直接编译不过。带javax的老代码就老老实实用 Tomcat 9,别硬上 10。

2.2 依赖与 web.xml:三处配置决定项目能不能起来

骨架的第一步是pom.xml。这个文件里最容易出问题的不是缺依赖,而是 scope 写错——Servlet API 被打进 war 包,和容器自带的版本冲突,启动时抛ClassCastException或者过滤器根本不生效。

<dependencies> <!-- Servlet API 由 Tomcat 提供,scope 必须是 provided; 写成 compile 会被打进 WEB-INF/lib,和容器里的实现打架 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSTL 是页面渲染用的,容器不带,必须打进 war,scope 用默认的 compile --> <dependency> <groupId>javax.servlet.jsp.jstl</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 8 驱动,注意包名是 com.mysql.cj.jdbc.Driver,不是老的 com.mysql.jdbc.Driver --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 连接池:不要每次请求都 DriverManager.getConnection,那是压测第一个崩的地方 --> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <version>4.0.3</version> </dependency> </dependencies> <build> <!-- 打成 war 才能丢进 Tomcat 的 webapps 目录 --> <finalName>weibo</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>3.3.2</version> </plugin> </plugins> </build>

finalName这一项别省,它决定了你部署后的访问路径。写成weibo,应用上下文就是/weibo,后面所有getContextPath()拼出来的地址都依赖它;改名之后如果 JSP 里写死了/static/css/app.css这类绝对路径,样式会整片 404。

依赖齐了之后是web.xml。很多人习惯用@WebFilter注解,省掉这个文件,但注解的问题是执行顺序不好控,两个过滤器谁先谁后完全看容器扫描的顺序。中文乱码和登录校验如果顺序反了,就会出现「未登录请求被重定向到登录页,登录页本身又是乱码」这种连环问题。我一般还是老老实实写web.xml,顺序肉眼可见。

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <display-name>mini-weibo</display-name> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> <!-- 编码过滤器放第一个:它要覆盖后面所有读写请求体的环节 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.example.weibo.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 登录校验只拦业务路径,静态资源交给容器直接返回,省一次 Session 查询 --> <filter> <filter-name>loginFilter</filter-name> <filter-class>com.example.weibo.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/weibo/*</url-pattern> </filter-mapping> <session-config> <!-- 默认 30 分钟,课程演示够用;上线一般缩到 15 分钟以内 --> <session-timeout>30</session-timeout> <cookie-config> <http-only>true</http-only> </cookie-config> </session-config> </web-app>

<url-pattern>的选择是有讲究的。编码过滤器必须用/*,因为它要管住所有请求体的读取;登录过滤器用/weibo/*就够了,如果把登录过滤器也写成/*,就得在代码里额外维护一份白名单,漏一个就是死循环重定向。<http-only>true</http-only>是让 JS 读不到JSESSIONID,防 XSS 顺手带走的成本几乎为零,加上没坏处。

2.3 目录结构:分层别乱,JSP 里不要出现 SQL

工程目录我一般按下面这样切,核心原则只有一条:JSP 只负责渲染,SQL 只出现在 dao 包里,任何一边越界,后面改需求就会痛苦。

mini-weibo/ ├── pom.xml └── src/main/ ├── java/com/example/weibo/ │ ├── filter/ # EncodingFilter、LoginFilter,只管横切逻辑 │ ├── servlet/ # 接参数、调 service、转发 JSP,不写业务判断 │ ├── service/ # 业务规则、事务边界(点赞、关注都在这层开事务) │ ├── dao/ # 只做 SQL 与结果集映射,一个方法一条 SQL │ ├── entity/ # User、Weibo、Follow,字段和表一一对应 │ ├── vo/ # WeiboVO 这类带 liked 标记的展示对象 │ ├── util/ # DBUtil、MD5Util、PageResult │ └── exception/ # BizException、DataAccessException ├── resources/ │ └── db.properties # 数据库账号密码,不进 git └── webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ # 放 JSP,外部不能直接访问,必须经 Servlet 转发 └── static/ # bootstrap.css、app.js、avatar/

WEB-INF/views这个位置值得单独说一句。JSP 放在WEB-INF下面,浏览器就没法直接敲地址访问,只能通过request.getRequestDispatcher("/WEB-INF/views/home.jsp").forward(...)进来。好处是数据装配一定先经过 Servlet,不会出现「用户直接访问 home.jsp,结果${timeline}是 null,页面一片空白」这种尴尬。代价是 JSP 里不能用相对路径引用静态资源,必须用${pageContext.request.contextPath}拼前缀。

至于一条请求怎么流过去:浏览器发GET /weibo/timeline?cursor=0,Tomcat 交给EncodingFilter设置编码,再进LoginFilter查 Session,校验通过后到TimelineServlet,它调WeiboService,Service 调WeiboDao查库,DAO 返回List<WeiboVO>,Servlet 塞进request.setAttribute,forward 到 JSP 由 JSTL 循环输出 HTML。整条线能在这张目录里逐个文件指出来,这就是原生方案的底气。

3. 数据模型设计:用户、微博、关注、点赞四张表的字段取舍

3.1 四张核心表与建表 SQL

个人微博应用的数据模型,说穿了就是四张表:谁、发了什么、关注了谁、赞了什么。难点不在字段数量,而在几个看起来可以偷懒的地方——比如把粉丝 ID 用逗号拼成一个字段,比如点赞数只在页面算不落库。前者会在「查我关注的人最近发了什么」时直接崩掉,后者会在分页时算出诡异的数字。

CREATE DATABASE IF NOT EXISTS mini_weibo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE mini_weibo; CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password CHAR(32) NOT NULL COMMENT 'MD5(明文 + salt) 的十六进制', salt CHAR(16) NOT NULL COMMENT '每个用户独立随机串', nickname VARCHAR(32) NOT NULL, avatar VARCHAR(255) DEFAULT NULL, bio VARCHAR(140) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_weibo ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, content VARCHAR(280) NOT NULL DEFAULT '', image_url VARCHAR(255) DEFAULT NULL, like_count INT NOT NULL DEFAULT 0, comment_count INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 个人主页按 user_id 倒序翻页用这个 KEY idx_user_id (user_id, id), -- 全站热门/最新用这个 KEY idx_created (created_at), CONSTRAINT fk_weibo_user FOREIGN KEY (user_id) REFERENCES t_user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_follow ( follower_id BIGINT NOT NULL COMMENT '关注者', followee_id BIGINT NOT NULL COMMENT '被关注者', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 联合主键天然防重复关注,不需要额外写"先查再插" PRIMARY KEY (follower_id, followee_id), KEY idx_followee (followee_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_like ( user_id BIGINT NOT NULL, weibo_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, weibo_id), KEY idx_weibo (weibo_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个字段的选择需要解释。content定成VARCHAR(280)而不是TEXT,是因为微博类产品的定位就是短内容,280 这个数字还能顺便在 Service 层做长度校验,前后端口径一致。like_count和comment_count做成冗余字段而不是每次COUNT(*),是为了首页列表能一条 SQL 出结果——如果列表里每条微博都去 count 一次,20 条就是 20 次额外查询,典型的 N+1 问题。冗余的代价是必须在写操作里保证一致,这一点在第 4 章的事务里解决。

utf8mb4而不是utf8也是必须的。MySQL 的utf8实际只有 3 字节,存不下 emoji,用户昵称里带个表情就会插入失败或者变成?,而微博类应用不带表情几乎不可能。

3.2 关注关系:为什么不用逗号拼粉丝 ID

我见过不少课程作业是这么设计的:t_user里加一个follow_ids VARCHAR(1000),关注一个人就往后面追加一个 ID,查关注列表时用split(",")拆开,再IN (...)去查。本地测试数据只有三五个用户时,跑得飞快。

问题会在两个地方集中出现。第一是「取消关注」,你要先查出字符串、在内存里删掉某个 ID、再拼回去,这中间只要有并发请求,就会丢更新。第二是「查我关注的人最近发的微博」,SQL 会变成WHERE user_id IN (?)拼一长串字符串,索引基本用不上,数据量一过万就开始明显变慢。

改成t_follow这张独立的关系表之后,两个问题都消失了。关注就是INSERT IGNORE,取关就是DELETE,查询关注的人的微博直接一个子查询搞定:

-- 我关注的人的 id 列表 SELECT followee_id FROM t_follow WHERE follower_id = ?; -- 我关注了多少人 / 多少人关注我(个人主页的两个数字) SELECT (SELECT COUNT(*) FROM t_follow WHERE follower_id = ?) AS following_cnt, (SELECT COUNT(*) FROM t_follow WHERE followee_id = ?) AS follower_cnt;

联合主键(follower_id, followee_id)是关键设计。它同时承担了唯一约束和索引两个职责:插入重复关注会直接抛主键冲突,业务层只要 catch 一下当成功处理就行,不用先 SELECT 再 INSERT——那个写法在并发下一定会出现两条重复记录。而idx_followee这个反向索引,是为了「谁关注了我」这个查询,没有它就得全表扫。

3.3 点赞表:用联合主键把重复点赞挡在数据库层

点赞的难点从来不是「加一」,而是「一个人只能赞一次」。如果把去重逻辑放在 Java 里,写成「先SELECT COUNT(*) FROM t_like WHERE ...,等于 0 才插入」,那在双击、或者网络重试的情况下就会插两条,like_count加两次。这种方式在本地演示时从来不会出问题,一到真用就翻车。

t_like用(user_id, weibo_id)做联合主键,就能直接利用主键的唯一约束:

-- 点过赞返回 1,重复点赞因主键冲突被忽略,返回 0 INSERT IGNORE INTO t_like(user_id, weibo_id) VALUES (?, ?); -- 只有真正插入成功(影响行数 1)才去更新计数 UPDATE t_weibo SET like_count = like_count + 1 WHERE id = ?;

INSERT IGNORE的语义是「遇到任何可忽略的错误都当没发生」,这既是它的优点也是隐患——如果weibo_id不存在导致的约束问题也会被吞掉。更严谨的写法是捕获SQLIntegrityConstraintViolationException,只处理主键冲突这一种。课程项目里用INSERT IGNORE够用,但你要清楚它把别的错也一起吞了。

还有个细节:like_count是INT,减法要靠GREATEST兜底,UPDATE t_weibo SET like_count = GREATEST(like_count - 1, 0) WHERE id = ?。取消赞的并发顺序如果错乱,裸减会减出负数,页面上显示「-1 个赞」,这是我在别人项目里真实见过的画面。

4. 核心链路拆解:发微博、刷首页、点赞三条请求的完整代码

4.1 连接池与 DAO:一条时间线 SQL 的写法

先说连接。课程作业最常见的是在 DAO 里写DriverManager.getConnection(url, user, pwd),然后每个方法里try-finally关一遍。单机测试没感觉,一压测就发现连接建立占了大部分耗时,而且关闭时机一旦写漏就是连接泄漏。换成 HikariCP 之后,初始化只在类加载时做一次:

// com.example.weibo.util.DBUtil public final class DBUtil { private static final HikariDataSource DS; static { HikariConfig cfg = new HikariConfig(); // useUnicode + characterEncoding 管住中文,serverTimezone 管住时间差 8 小时 cfg.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/mini_weibo" + "?useUnicode=true&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai&useSSL=false"); cfg.setUsername("weibo"); cfg.setPassword("weibo"); cfg.setMaximumPoolSize(10); // 课程项目 10 足够,别照着教程写 100 cfg.setMinimumIdle(2); cfg.setConnectionTimeout(3000); // 3 秒拿不到连接就抛异常,绝不无限等 cfg.setMaxLifetime(1_800_000); // 30 分钟,短于 MySQL 的 wait_timeout cfg.setPoolName("weibo-pool"); DS = new HikariDataSource(cfg); } public static Connection getConnection() throws SQLException { return DS.getConnection(); } private DBUtil() { } }

参数里有三个值得盯住。maximumPoolSize不是越大越好,每个连接在数据库端都占一个线程和内存,10 个连接对个人微博应用已经足够,调到 100 只会在数据库侧排队。connectionTimeout设成 3 秒,是为了让「池子耗尽」这件事快速暴露成异常,而不是让用户对着转圈的页面等 30 秒。maxLifetime一定要短于 MySQL 的wait_timeout(默认 8 小时),否则连接被服务端单方面断开、客户端还以为可用,报Communications link failure,这个问题在晚上跑的项目上特别常见。

然后是首页时间线这条最核心的查询。业务语义是「我自己 + 我关注的人发的微博,按时间倒序,滚动加载」。分页我不用LIMIT offset, size,改用游标:

// com.example.weibo.dao.WeiboDao /** * @param userId 当前登录用户 * @param cursor 上一页最后一条微博的 id,首次加载传 null * @param size 每页条数,建议 10~20 */ public List<WeiboVO> pageTimeline(long userId, Long cursor, int size) { StringBuilder sql = new StringBuilder( "SELECT w.id, w.content, w.image_url, w.like_count, w.comment_count, w.created_at, " + " u.id AS uid, u.nickname, u.avatar, " + // 逐条判断当前用户是否已赞,避免前端再发一轮请求 " EXISTS(SELECT 1 FROM t_like l WHERE l.weibo_id = w.id AND l.user_id = ?) AS liked " + "FROM t_weibo w JOIN t_user u ON u.id = w.user_id " + "WHERE (w.user_id = ? OR w.user_id IN " + " (SELECT followee_id FROM t_follow WHERE follower_id = ?)) "); if (cursor != null) { // 用自增 id 当游标:新微博插入不会让已加载的页发生位移 sql.append("AND w.id < ? "); } sql.append("ORDER BY w.id DESC LIMIT ?"); List<WeiboVO> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { int i = 1; ps.setLong(i++, userId); // liked 子查询 ps.setLong(i++, userId); // 自己发的 ps.setLong(i++, userId); // 关注的人的 if (cursor != null) { ps.setLong(i++, cursor); } ps.setInt(i, size); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { WeiboVO vo = new WeiboVO(); vo.setId(rs.getLong("id")); vo.setContent(rs.getString("content")); vo.setImageUrl(rs.getString("image_url")); vo.setLikeCount(rs.getInt("like_count")); vo.setCommentCount(rs.getInt("comment_count")); vo.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime()); vo.setAuthorId(rs.getLong("uid")); vo.setAuthorName(rs.getString("nickname")); vo.setAuthorAvatar(rs.getString("avatar")); vo.setLiked(rs.getBoolean("liked")); // 页面据此渲染红心 list.add(vo); } } } catch (SQLException e) { throw new DataAccessException("查询时间线失败, userId=" + userId, e); } return list; }

参数说明:userId决定了能看谁的微博;cursor传null表示首页第一屏,之后每次都传上一页最后一条的id;size限制了单次返回条数,接口层要卡上限(比如最大 50),不然有人手动把size改成 10000,就是一次全表返回。

为什么不用OFFSET?因为时间线是持续有新内容插入的。用LIMIT 20 OFFSET 20拉第二页时,如果这期间有人发了 3 条新微博,原来第 20 条会被顶到第 23 位,第二页开头就会出现你刚刚才看过的内容。这个现象在用户那边表现是「刷着刷着重复了」,用 id 游标就没有这个问题,因为条件变成了id < 上一页最小的 id,位置不会漂移。

liked那个EXISTS子查询,有人会担心性能。在t_like上有(user_id, weibo_id)的主键,这个判断是索引命中的,单页 20 条也就是 20 次极快的索引查找,比让前端对每条微博再发一次「是否已赞」的请求划算得多。

4.2 发微博:参数校验放在哪一层

发微博看着简单,实际上校验放错层会很难受。放 Servlet 里,那定时任务、后台管理再调一次就得重写;放 DAO 里,DAO 就背上了业务规则,单测也难写。正确的做法是 SKIP 掉 Servlet 和 DAO,全部塞进 Service,Servlet 只负责收集参数和转发。

// com.example.weibo.service.WeiboService public long post(long userId, String content, String imageUrl) { content = content == null ? "" : content.trim(); // 纯文字和纯图片都允许,但两个都空就不行 if (content.isEmpty() && (imageUrl == null || imageUrl.isEmpty())) { throw new BizException("说点什么再发吧"); } if (content.length() > 280) { throw new BizException("最多 280 个字"); } String sql = "INSERT INTO t_weibo(user_id, content, image_url) VALUES(?,?,?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setLong(1, userId); ps.setString(2, content); ps.setString(3, imageUrl); ps.executeUpdate(); try (ResultSet rs = ps.getGeneratedKeys()) { return rs.next() ? rs.getLong(1) : -1L; // 返回新微博 id,便于前端跳详情 } } catch (SQLException e) { throw new DataAccessException("发布微博失败", e); } }

Statement.RETURN_GENERATED_KEYS这个参数容易被忽略,但它决定了getGeneratedKeys()能不能拿到值。默认不返回自增主键,很多人写了rs.next()却永远进不去,最后只能再查一次SELECT MAX(id)——在那个查询和插入之间只要有人插了一条,就拿错了。

trim()那行也别删。用户手滑敲一个空格点发布,不 trim 的话content.length()是 1,校验通过,时间线上就多出一条肉眼看不见的空微博。

Servlet 那边就三件事,取参数、调 Service、决定去哪:

// com.example.weibo.servlet.PostWeiboServlet @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { User loginUser = (User) req.getSession().getAttribute("loginUser"); String content = req.getParameter("content"); // 图片先不管,upload 逻辑单独一个 servlet 处理,这里只接 URL String imageUrl = req.getParameter("imageUrl"); try { long id = weiboService.post(loginUser.getId(), content, imageUrl); // PRG 模式:POST 之后重定向,避免用户刷新导致重复发布 resp.sendRedirect(req.getContextPath() + "/weibo/detail?id=" + id); } catch (BizException e) { req.setAttribute("error", e.getMessage()); req.setAttribute("draft", content); // 回填草稿,别让用户白写 req.getRequestDispatcher("/WEB-INF/views/post.jsp").forward(req, resp); } }

这个 Servlet 里最值钱的一行是sendRedirect。POST 成功后如果直接forward到列表页,用户按 F5 浏览器会问「是否重新提交表单」,确认一下就发了两条一样的微博。改成重定向之后,刷新只是重新 GET 详情页,天然幂等。这个模式叫 PRG(Post-Redirect-Get),写表单类功能时我基本默认这么干。

4.3 点赞:一个事务里的两条写操作

点赞涉及两次写:t_like插一条,t_weibo的like_count加一。这两步必须在一个事务里,否则中途失败就会出现「点赞记录有了但计数没加」或者反过来。数据库连接默认autoCommit = true,每条语句独立提交,所以这里必须手动关掉。

// com.example.weibo.service.LikeService public boolean like(long userId, long weiboId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关掉自动提交,两条写要么全成要么全回滚 int rows; try (PreparedStatement ps = conn.prepareStatement( "INSERT IGNORE INTO t_like(user_id, weibo_id) VALUES(?,?)")) { ps.setLong(1, userId); ps.setLong(2, weiboId); rows = ps.executeUpdate(); // 0 表示已经赞过,什么都不用做 } if (rows == 1) { try (PreparedStatement ps = conn.prepareStatement( "UPDATE t_weibo SET like_count = like_count + 1 WHERE id = ?")) { ps.setLong(1, weiboId); ps.executeUpdate(); } } conn.commit(); return rows == 1; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignore) { // 回滚本身失败只能记日志,不能再往外抛,否则掩盖原始异常 } } throw new DataAccessException("点赞失败", e); } finally { if (conn != null) { try { conn.setAutoCommit(true); // 归还连接前恢复默认值,否则污染连接池 conn.close(); } catch (SQLException ignore) { } } } }

setAutoCommit(true)这行是很多人漏掉的。连接池是复用连接的,你借出来时改了autoCommit,还回去时没恢复,下一个借到这条连接的业务代码就会莫名其妙不提交事务,排查起来非常痛苦。同理,try-with-resources在这里没法直接用,因为Connection的关闭要放在恢复autoCommit之后。

还有一个容易漏的点:取消赞(unlike)同样要在一个事务里删记录、减计数,并且用GREATEST(like_count - 1, 0)兜底。至于「点赞数对不对」,最好再写一个定时对账任务,每隔几分钟跑一次SELECT weibo_id, COUNT(*) FROM t_like GROUP BY weibo_id和t_weibo.like_count比对,差异超过阈值的打日志。缓存和计数这类东西,没有对账就没有后悔药。

4.4 登录过滤器与 Session:拦截路径的边界

登录态这块,个人微博应用不需要 JWT,Session 就够了。关键在过滤器的写法,特别是「AJAX 请求未登录」这个分支——如果一律sendRedirect到登录页,前端fetch拿到的是登录页的 HTML,response.json()直接抛解析异常,控制台报的错跟登录压根没关系。

// com.example.weibo.filter.LoginFilter public class LoginFilter implements Filter { // 白名单:登录、注册、静态资源不用校验 private static final Set<String> WHITE = Set.of("/login", "/register", "/doLogin", "/doRegister"); @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 去掉 contextPath,只留业务路径,否则部署名一换白名单就失效 String path = request.getRequestURI() .substring(request.getContextPath().length()); if (WHITE.contains(path) || path.startsWith("/static/")) { chain.doFilter(req, resp); return; } // 传 false:没有会话就不要新建,避免给爬虫也发一个 session HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With")) || "fetch".equals(request.getHeader("Sec-Fetch-Mode"))) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}"); } else { // 记住原地址,登录后跳回去 String target = URLEncoder.encode( request.getRequestURI() + "?" + (request.getQueryString() == null ? "" : request.getQueryString()), StandardCharsets.UTF_8); response.sendRedirect(request.getContextPath() + "/login?redirect=" + target); } return; } chain.doFilter(req, resp); } }

这段代码里三个点值得记住。getRequestURI().substring(contextPath.length())是必须的,因为getRequestURI()返回的是/weibo/weibo/post这种带上下文前缀的完整路径,直接拿去和白名单里的/post比永远不相等,结果是登录页本身也被拦,形成无限重定向,浏览器报「重定向次数过多」。

getSession(false)里的false是性能和安全的双重考虑。传true的话,每个未登录的请求都会创建一个 Session 对象存在内存里,被人用脚本刷几千次就是几千个 Session 常驻,堆内存会涨。传false表示「有就取,没有返回 null」,符合过滤器「只判断不创建」的定位。

登录成功时的 Session 写入也别太随意:

// 登录成功后 HttpSession old = req.getSession(false); if (old != null) { old.invalidate(); // 防会话固定攻击:登录前后必须是不同的 sessionId } HttpSession session = req.getSession(true); session.setAttribute("loginUser", user); // 只存必要字段,别把整个 User 塞进去 session.setMaxInactiveInterval(30 * 60);

invalidate()再新建这一步很多人省了,理论上存在会话固定风险。loginUser里也别放密码字段,Session 对象会被序列化到磁盘(Tomcat 重启时),放明文密码就是给自己埋雷。

5. 常见问题排查:换台机器就白屏的 5 组现象与解法

5.1 现象:本机跑得好好的,换台机器部署后访问 500

现象是本地 Tomcat 里一切正常,打成 war 包丢到另一台机器的webapps下,日志里刷ClassNotFoundException: com.mysql.cj.jdbc.Driver。

原因基本锁定在依赖作用域。项目用了 Maven,但依赖写在<scope>provided</scope>里,Maven 就认为「容器会提供」,打包时不会放进WEB-INF/lib。本机之所以能跑,是因为 IDE 的运行配置把全部依赖挂到了 classpath 上,掩盖了这个问题。

解决分两步。先确认pom.xml里 MySQL 驱动和 HikariCP 没有provided,只有javax.servlet-api该是provided。然后拆开 war 包验证:unzip -l target/weibo.war | grep mysql,能看到WEB-INF/lib/mysql-connector-java-8.0.33.jar才算对。这个检查我一般写进部署脚本,比启动后再看日志快。

5.2 现象:POST 上来的中文变成问号,GET 参数却是正常的

现象是发微博时,中文内容存进库里是???,但页面 URL 里带的搜索关键词显示正常。

原因是编码设置没覆盖全链路,而 GET 和 POST 的编码来源不同。POST 请求体的编码由request.setCharacterEncoding("UTF-8")决定,必须在第一次getParameter之前调用;GET 的 query string 在 Tomcat 8 之后默认就是 UTF-8,所以看起来正常。很多时候代码里写了setCharacterEncoding,但它被放在了getParameter之后,等于没写。

解决要动三处,缺一不可:过滤器里在chain.doFilter之前调request.setCharacterEncoding("UTF-8")和response.setCharacterEncoding("UTF-8");JDBC URL 上带useUnicode=true&characterEncoding=utf8;建库建表统一utf8mb4。三个地方任意一处用了utf8(3 字节)而不是utf8mb4,emoji 都会挂。

5.3 现象:首页往下刷,第二页出现第一页已经看过的微博

现象很直观:滚动加载到第二屏,开头两条和第一屏最后两条一模一样,用户以为重复发了。

原因是LIMIT offset, size分页在数据持续写入时会发生位移。你查第一页时OFFSET 0,翻第二页时数据表里多了 3 条新记录,原来的第 18、19、20 条被挤到了第 21、22、23 位,于是OFFSET 20查出来的正好是刚看过的那几条。

解决办法就是第 4 章那段代码里的游标分页,把OFFSET换成AND w.id < ?,?传上一页最后一条的 id。改完之后前端也要配合,把cursor存成响应里最后一条的id,而不是维护一个页码变量。顺带说一句,游标分页还有个好处是深翻页不会越来越慢,OFFSET 10000是要扫过一万行的。

5.4 现象:点赞数越点越多,取消赞之后数字不减少

现象是同一用户反复点同一条微博,红心没变化,但旁边的数字一路往上加。

原因是去重逻辑写在了 Java 层。典型写法是先SELECT COUNT(*) FROM t_like WHERE user_id=? AND weibo_id=?,判断为 0 再插入。单线程点一次没问题,但用户双击、或者前端重试,两个请求几乎同时执行,都查到 0,都插入成功——如果t_like上还没有唯一约束,就是两条记录加上两次like_count + 1。

解决办法是把去重下沉到数据库:t_like用(user_id, weibo_id)作主键,插入用INSERT IGNORE,靠executeUpdate()的返回值判断是否真的新增了。已经有数据的旧表要先清重复再加主键:DELETE t1 FROM t_like t1 JOIN t_like t2 ON t1.user_id=t2.user_id AND t1.weibo_id=t2.weibo_id AND t1.created_at > t2.created_at;,然后ALTER TABLE t_like ADD PRIMARY KEY(user_id, weibo_id)。

5.5 现象:JSP 页面能出来,但 CSS 和 JS 全部 404

现象是页面结构在,样式没了,控制台一片GET /static/css/bootstrap.css 404。

原因有两类。一类是路径问题:JSP 放在WEB-INF/views/下,页面里写<link href="static/css/bootstrap.css">是相对路径,浏览器会以当前请求路径为基准去拼,/weibo/weibo/detail下就拼成了/weibo/weibo/static/...。另一类是过滤器把静态资源也拦了,LoginFilter的url-pattern写了/*,又没做静态资源放行,未登录时访问 CSS 直接被重定向到登录页,浏览器拿到的是 HTML,MIME 对不上。

前者的解决是统一用${pageContext.request.contextPath}打头,一行<c:set var="ctx" value="${pageContext.request.contextPath}"/>,后面全用${ctx}/static/...。后者要么把url-pattern收窄到业务路径,要么在过滤器里显式放行path.startsWith("/static/")。改完之后记得强刷缓存,浏览器把 404 的结果也缓存住的情况我遇到过好几次。

6. 二次开发:把课程作业改成能写进简历的项目

课程项目交了之后想再往前一步,按投入产出比排序,我一般建议走三个方向。第一个是迁移到 Spring Boot,把 Servlet、Filter、DBUtil 这几层换成@RestController、OncePerRequestFilter、@Mapper,业务 Service 几乎原样搬过去。迁移过程中你会真正理解「框架帮你做了什么」——原来手写的参数绑定、事务提交、连接归还,现在都是注解在背后干活。第二个是前后端分离,把 JSP 换成 Thymeleaf 或者纯静态页 +fetch调接口,登录态从 Session 改成 JWT 或者 Cookie + 拦截器。这一步能暴露跨域、鉴权、接口版本管理这些真实工程问题。第三个是性能方向,给首页时间线加 Redis 缓存、图片上传改用对象存储、like_count走异步写入。

三条路线对应的工作量和风险差别不小,列成表更直观:

改造方向主要动哪几层收益主要风险
迁 Spring Bootservlet/dao/util 全换简历上能写主流栈,代码量减半依赖版本冲突,MyBatis 映射容易写错
前后端分离servlet 改 Controller,JSP 改前端页能体现接口设计能力鉴权与跨域要重新设计,容易出安全漏洞
加缓存与异步service 层加 Redis,点赞走消息队列能讲清楚高并发下的取舍缓存与数据库不一致,需要补对账

不管改哪条,验证方式别只靠「打开浏览器点一遍」。我习惯用 curl 把主链路跑一遍,脚本化之后每次改动都能复跑:

# 1. 登录,把 JSESSIONID 存进 cookie.txt curl -i -c cookie.txt -X POST http://localhost:8080/weibo/doLogin \ -d "username=demo&password=123456" # 2. 发一条微博,期望 302 且 Location 指向详情页 curl -i -b cookie.txt -X POST http://localhost:8080/weibo/weibo/post \ -d "content=改造后的第一条测试" # 3. 拉时间线,确认刚发的那条在返回里,且 liked 字段是 false curl -s -b cookie.txt "http://localhost:8080/weibo/weibo <p> <a href="https://download.csdn.net/download/m0_50509016/87592493" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 10:24:40

FPGA万年历设计:从格里高利历算法到数码管动态扫描实现

简介&#xff1a;本资源是面向高校电子类、通信类专业本科生的FPGA数字系统课程设计完整实践包&#xff0c;聚焦万年历功能实现这一典型时序逻辑综合项目。内容覆盖从需求分析、Verilog/VHDL代码开发、Quartus II工程构建到硬件调试全流程&#xff0c;特别适合作为数字电路、ED…

作者头像 李华
网站建设 2026/10/30 10:24:39

Java+Swing数独小游戏课程设计:回溯算法与界面开发实战解析

简介&#xff1a;基于Java与Swing界面库编写的数独小游戏&#xff0c;是一份适合Java课程设计场景的完整源码与资源文件包&#xff0c;面向正在学习Java图形界面编程、需要完成小游戏类课设作业的高校学生&#xff0c;也可作为Swing布局管理、事件监听与基础算法实现的练手样例…

作者头像 李华
网站建设 2026/10/30 10:22:40

从电到光:光引擎、光模块、光纤、OCS、FAU一文讲透

从“电”到“光”&#xff1a;一次讲透光引擎、光模块、光纤、OCS、FAU这些年我在IDC机房和网络项目里来回跑&#xff0c;一个很明显的感受是&#xff1a;凡是用铜缆的地方&#xff0c;都在慢慢被光替代。机柜里的交换互联从10G铜缆变成25G、100G光模块&#xff0c;机房之间的骨…

作者头像 李华
网站建设 2026/10/30 10:22:06

级联扩散模型:高分辨率图像生成技术解析

1. 级联扩散模型技术解析 级联扩散&#xff08;Cascade Diffusion&#xff09;是当前扩散模型领域的前沿技术之一&#xff0c;它通过多阶段级联的生成方式显著提升了生成质量。我在实际项目中验证过&#xff0c;相比传统单阶段扩散模型&#xff0c;级联结构能将图像生成分辨率提…

作者头像 李华
网站建设 2026/10/30 10:21:56

注意力机制在无线网络流量预测中的应用与优化

1. 项目概述 "注意力机制助力无线网络流量精准预测"这个项目标题揭示了当前网络流量预测领域的一个前沿研究方向。作为一名长期从事网络优化的工程师&#xff0c;我深刻理解无线网络流量预测对于网络资源调度、服务质量保障和用户体验提升的重要性。 传统的流量预测…

作者头像 李华
网站建设 2026/10/30 10:12:36

零基础安卓应用上架谷歌市场全流程指南

简介&#xff1a;面向准备出海或初次接触海外分发的 Android 开发者&#xff0c;这份 PDF 文档系统梳理了 Google Play 应用市场上架的全流程与常见坑点&#xff0c;适合已注册开发者账号、需要把应用正式发布到谷歌商店的初中级开发者对照操作。资源包共 1 个 PDF 文件&#x…

作者头像 李华