简介:这是一套基于JSP与MySQL开发的轻量级个人记事备忘系统源码,面向Java Web初学者及课程设计实践者,解决日常笔记在线化管理需求,适用于高校Web开发实训、毕业设计参考或MVC模式入门项目。资源共374个文件,涵盖24个Java后端类(含Servlet与DAO)、9个JSP页面(如note.jsp、index.jsp)、28个CSS与86个JS前端资源(含Bootstrap、UEditor、video.js等成熟组件),以及97张PNG界面截图和46个GIF动效图,完整呈现前后端交互与UI细节;压缩包大小27.51MB。已有109人学习下载,提供可直接部署到Tomcat的完整工程结构(含web.xml配置、JDBC连接池设置、SQL建表语句及初始化数据),附带清晰的目录分层(notes-jsp主模块)、常见SQL注入防护示例与基础日志记录机制,是理解JSP+Servlet+MySQL三层架构落地的典型教学案例。
1. 为什么一个“个人记事备忘系统”值得用 JSP + MySQL 从零搭起:它不是练手玩具,而是你理解 Web 开发闭环的最小可信单元
你可能在简历里写过“熟悉 Java Web 开发”,但真能说清请求怎么从浏览器进 Servlet、Session 怎么跨页面存用户状态、MySQL 的datetime和timestamp在插入时到底谁听谁的?这个标题里的“个人记事备忘系统”,恰恰是唯一能让你把 JDBC 连接池、JSP 页面跳转逻辑、表单 CSRF 防御、甚至 Tomcat 日志定位错误全串起来的真实切口。它不追求高并发,但要求每一步都可追踪、可打断、可重放——比如你删掉一条备忘后刷新页面,发现它又回来了,那一定是事务没提交,或者前端缓存没清;你改完密码却登不进去,大概率是password字段用了VARCHAR(20)却存了 BCrypt 加密后的 60 位字符串。这不是教科书里的 Hello World,而是一套带血丝的实战切片:它小到能一上午跑通,又厚到能挖出你三年都没意识到的底层盲区。适合刚学完 Servlet 生命周期、正卡在“写了代码但不知道哪步断了”的 Java 初学者,也适合想带新人快速建立 Web 请求链路直觉的带教工程师。
2. 搭建环境与初始化数据库:避开 JDK 版本错配和 MySQL 8.0+ 默认认证插件这两大“静默杀手”
2.1 环境版本锁定:为什么必须用 JDK 8u202 + Tomcat 9.0.83 + MySQL 8.0.33(而非最新版)
很多新手直接下最新 JDK 21 或 MySQL 8.4,结果连Class.forName("com.mysql.cj.jdbc.Driver")都报ClassNotFoundException。根本原因在于:JDK 9+ 移除了javax.servlet包,而老版 JSP 编译器(jasper.jar)仍强依赖它;MySQL 8.0.4+ 默认启用caching_sha2_password认证插件,但mysql-connector-java:5.1.47(JSP 项目最常用驱动)根本不认识它。我一般会强制降级并固化三件套:
# 下载地址(均来自官方归档页,非第三方镜像) # JDK 8u202: https://adoptium.net/zh-CN/temurin/releases/?version=8 # Tomcat 9.0.83: https://archive.apache.org/dist/tomcat/tomcat-9/v9.0.83/bin/ # MySQL 8.0.33: https://downloads.mysql.com/archives/community/提示:解压后务必检查
JAVA_HOME指向 JDK 8 目录,且PATH中java -version输出必须为1.8.0_202;Tomcat 启动日志第一行若出现Server version: Apache Tomcat/9.0.83才算落定。
2.2 创建 MySQL 数据库与用户:用utf8mb4而非utf8,并显式指定认证插件
执行以下 SQL(注意不是在 phpMyAdmin 图形界面点点点,而是用命令行mysql -u root -p进入后逐行粘贴):
-- 1. 创建数据库,字符集必须为 utf8mb4(支持 emoji 和生僻字) CREATE DATABASE IF NOT EXISTS memo_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; -- 2. 创建专用用户(禁止 root 远程直连!) CREATE USER 'memo_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'MemoPass123!'; -- 3. 授予最小权限(只给 memo_db 的 CRUD) GRANT SELECT, INSERT, UPDATE, DELETE ON memo_db.* TO 'memo_user'@'localhost'; -- 4. 刷新权限立即生效 FLUSH PRIVILEGES;关键参数说明:
utf8mb4_unicode_ci:比utf8mb4_general_ci更准的排序规则,对中文标点兼容性更好;IDENTIFIED WITH mysql_native_password:强制使用旧版认证插件,确保 JDBC 5.1.x 驱动能握手成功;'memo_user'@'localhost':限定只能本机连接,杜绝远程爆破风险。
2.3 验证数据库连通性:写一个独立的TestDB.java,不依赖任何 Web 容器
在任意空文件夹下新建TestDB.java:
import java.sql.*; public class TestDB { public static void main(String[] args) { String url = "jdbc:mysql://localhost:3306/memo_db?useSSL=false&serverTimezone=Asia/Shanghai"; String user = "memo_user"; String password = "MemoPass123!"; try (Connection conn = DriverManager.getConnection(url, user, password)) { System.out.println("✅ 数据库连接成功!"); // 额外验证:查一下当前时间,确认时区设置生效 try (Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT NOW() as now_time")) { if (rs.next()) { System.out.println("⏰ 当前 MySQL 时间:" + rs.getTimestamp("now_time")); } } } catch (SQLException e) { System.err.println("❌ 连接失败:" + e.getMessage()); e.printStackTrace(); } } }编译运行:
# 先下载 mysql-connector-java-5.1.47.jar(官网归档页有) javac -cp ".:mysql-connector-java-5.1.47.jar" TestDB.java java -cp ".:mysql-connector-java-5.1.47.jar" TestDB现象 → 原因 → 解决:
- 若报
Access denied for user:密码输错或用户创建时没加@'localhost',重新执行CREATE USER; - 若报
Unknown system variable 'tx_isolation':MySQL 版本过高(>8.0.33),降级到 8.0.33; - 若报
The server time zone value 'XXX' is unrecognized:URL 中serverTimezone=Asia/Shanghai拼写错误,注意大小写和斜杠。
3. 设计核心数据表与 JDBC 工具类:用PreparedStatement防注入,用try-with-resources确保连接释放
3.1 两张表足矣:users存账号,memos存备忘,拒绝过度设计
很多初学者一上来就加categories表、tags表、share_logs表……结果连登录都调不通。真实需求很简单:一个用户对应多条备忘,每条备忘有标题、内容、创建时间、是否完成。精简到极致的 SQL:
-- users 表:仅存登录凭证,密码必须加密存储(后续章节实现) CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, -- BCrypt 加密后长度约 60 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- memos 表:user_id 外键关联 users.id,软删除用 is_deleted 字段 CREATE TABLE memos ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, title VARCHAR(200) NOT NULL, content TEXT, is_done TINYINT(1) DEFAULT 0, -- 0=未完成,1=已完成 is_deleted TINYINT(1) DEFAULT 0, -- 0=正常,1=已删除(回收站逻辑) created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意:
ON DELETE CASCADE是关键——当用户注销时,自动删掉其所有备忘,避免外键残留。
3.2 封装DBUtil:用静态块加载驱动,用ThreadLocal隔离连接(非连接池,够用)
在src/com/utils/DBUtil.java中写:
package com.utils; import java.sql.*; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/memo_db?useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "memo_user"; private static final String PASSWORD = "MemoPass123!"; // 1. 静态块加载驱动(JDBC 4.0+ 可省略,但显式写更稳) static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL Driver not found!", e); } } // 2. 获取连接(每次调用都新建,无连接池,教学场景够用) public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 3. 关闭资源(重点:按 ResultSet → Statement → Connection 逆序关) public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) try { rs.close(); } catch (SQLException e) { /* 忽略 */ } if (stmt != null) try { stmt.close(); } catch (SQLException e) { /* 忽略 */ } if (conn != null) try { conn.close(); } catch (SQLException e) { /* 忽略 */ } } }为什么不用 HikariCP?
因为初学者先要理解“连接是什么”“为什么不能全局共用一个 Connection”。等你能手动写出事务回滚、批量插入、连接泄漏检测,再上连接池不迟。
3.3 写第一个 DAO:UserDAO的登录校验,用PreparedStatement绑定参数防 SQL 注入
在src/com/dao/UserDAO.java中:
package com.dao; import com.utils.DBUtil; import java.sql.*; public class UserDAO { // 根据用户名查用户(用于登录校验) public static User findByUsername(String username) { String sql = "SELECT id, username, password FROM users WHERE username = ? AND is_deleted = 0"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, username); // ✅ 参数化,杜绝 'admin' OR '1'='1' 注入 try (ResultSet rs = pstmt.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); // 密码只取值,不暴露 return user; } } } catch (SQLException e) { e.printStackTrace(); } return null; // 用户不存在 } // 插入新用户(注册) public static int insert(User user) { String sql = "INSERT INTO users (username, password) VALUES (?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { pstmt.setString(1, user.getUsername()); pstmt.setString(2, user.getPassword()); // 此处应为 BCrypt 加密后字符串 int affectedRows = pstmt.executeUpdate(); if (affectedRows == 0) { throw new SQLException("Creating user failed, no rows affected."); } try (ResultSet generatedKeys = pstmt.getGeneratedKeys()) { if (generatedKeys.next()) { return generatedKeys.getInt(1); // 返回自增主键 } else { throw new SQLException("Creating user failed, no ID obtained."); } } } catch (SQLException e) { e.printStackTrace(); return -1; } } }关键细节:
pstmt.setString(1, username):?占位符必须用setXxx()绑定,不能字符串拼接;Statement.RETURN_GENERATED_KEYS:获取AUTO_INCREMENT主键,注册后跳转到用户主页要用;try-with-resources:自动关闭ResultSet/Statement/Connection,避免连接泄漏。
4. 实现用户注册与登录流程:用 Session 管理状态,用request.getRequestDispatcher()控制跳转
4.1 注册页面register.jsp:表单提交到RegisterServlet,用EL 表达式回显错误
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head><title>用户注册</title></head> <body> <h2>注册新账户</h2> <!-- 1. 显示上一步传来的错误信息 --> <c:if test="${not empty errorMsg}"> <p style="color:red">${errorMsg}</p> </c:if> <form action="RegisterServlet" method="post"> <label>用户名:<input type="text" name="username" required></label><br> <label>密码:<input type="password" name="password" required></label><br> <label>确认密码:<input type="password" name="confirmPassword" required></label><br> <input type="submit" value="注册"> </form> </body> </html>注意:
<c:if>是 JSTL 标签,需在WEB-INF/lib下放入jstl-1.2.jar,并在 JSP 顶部加<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>。
4.2RegisterServlet:校验逻辑前置,密码用 BCrypt 加密,注册成功后重定向到登录页
@WebServlet("/RegisterServlet") public class RegisterServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取表单参数 String username = request.getParameter("username"); String password = request.getParameter("password"); String confirmPassword = request.getParameter("confirmPassword"); // 2. 基础校验(空值、长度、一致性) if (username == null || username.trim().length() < 3) { request.setAttribute("errorMsg", "用户名至少3位"); request.getRequestDispatcher("register.jsp").forward(request, response); return; } if (!password.equals(confirmPassword)) { request.setAttribute("errorMsg", "两次输入的密码不一致"); request.getRequestDispatcher("register.jsp").forward(request, response); return; } // 3. BCrypt 加密(引入 jbcrypt-0.4.jar) String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt(12)); // 4. 调用 DAO 插入数据库 User user = new User(); user.setUsername(username); user.setPassword(hashedPassword); int userId = UserDAO.insert(user); if (userId > 0) { // 注册成功,重定向到登录页(避免刷新重复提交) response.sendRedirect("login.jsp?msg=注册成功,请登录"); } else { request.setAttribute("errorMsg", "注册失败,请重试"); request.getRequestDispatcher("register.jsp").forward(request, response); } } }为什么用response.sendRedirect()而非forward()?
因为forward()是服务器内部跳转,浏览器地址栏仍是/RegisterServlet,用户刷新会重复提交表单;sendRedirect()是 302 重定向,地址栏变成/login.jsp,刷新安全。
4.3 登录LoginServlet:查库比对密码,正确则存HttpSession,错误则返回错误提示
@WebServlet("/LoginServlet") public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String password = request.getParameter("password"); // 1. 查询用户 User user = UserDAO.findByUsername(username); if (user == null) { request.setAttribute("errorMsg", "用户名不存在"); request.getRequestDispatcher("login.jsp").forward(request, response); return; } // 2. BCrypt 密码比对(明文密码 vs 数据库加密串) if (BCrypt.checkpw(password, user.getPassword())) { // 登录成功:存 session,并跳转到首页 HttpSession session = request.getSession(); session.setAttribute("user", user); // 存整个 User 对象,后续页面可取 session.setMaxInactiveInterval(1800); // 30分钟无操作过期 response.sendRedirect("index.jsp"); } else { request.setAttribute("errorMsg", "密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } } }Session 安全要点:
setMaxInactiveInterval(1800):强制会话 30 分钟失效,防止长期挂起;- 不存密码、不存敏感字段,只存
id和username; - 生产环境需配合
HttpOnlyCookie(Tomcat 9 默认开启)。
5. 备忘 CRUD 与 Session 权限控制:用 Filter 拦截未登录访问,用is_done字段实现状态切换
5.1 写一个AuthFilter:所有/memo/*请求必须先过登录校验
在src/com/filter/AuthFilter.java中:
@WebFilter(filterName = "AuthFilter", urlPatterns = "/memo/*") public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 1. 获取 session 中的 user HttpSession session = req.getSession(false); User user = (User) (session != null ? session.getAttribute("user") : null); // 2. 未登录则重定向到登录页,并保存原请求路径(登录后跳回) if (user == null) { String originalUrl = req.getRequestURL().toString(); if (req.getQueryString() != null) { originalUrl += "?" + req.getQueryString(); } req.getSession().setAttribute("originalUrl", originalUrl); resp.sendRedirect(req.getContextPath() + "/login.jsp?msg=请先登录"); return; } // 3. 已登录,放行 chain.doFilter(request, response); } }提示:
@WebFilter注解需 Tomcat 7+,若用低版本需在web.xml中配置<filter>。
5.2MemoServlet实现增删改查:用user_id关联当前登录用户,杜绝越权
@WebServlet("/memo/*") public class MemoServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String pathInfo = request.getPathInfo(); // /memo/list → pathInfo="/list" if ("/list".equals(pathInfo)) { listMemos(request, response); } else if ("/add".equals(pathInfo)) { request.getRequestDispatcher("/memo/add.jsp").forward(request, response); } else if ("/edit".equals(pathInfo)) { editMemo(request, response); } else if ("/delete".equals(pathInfo)) { deleteMemo(request, response); } } protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String pathInfo = request.getPathInfo(); if ("/add".equals(pathInfo)) { addMemo(request, response); } else if ("/update".equals(pathInfo)) { updateMemo(request, response); } } private void listMemos(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { User user = (User) request.getSession().getAttribute("user"); List<Memo> memos = MemoDAO.findByUserId(user.getId()); request.setAttribute("memos", memos); request.getRequestDispatcher("/memo/list.jsp").forward(request, response); } private void addMemo(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { User user = (User) request.getSession().getAttribute("user"); String title = request.getParameter("title"); String content = request.getParameter("content"); Memo memo = new Memo(); memo.setUserId(user.getId()); memo.setTitle(title); memo.setContent(content); memo.setIsDone(0); MemoDAO.insert(memo); response.sendRedirect("list"); // 重定向到列表页 } private void deleteMemo(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int id = Integer.parseInt(request.getParameter("id")); User user = (User) request.getSession().getAttribute("user"); // ✅ 关键:WHERE user_id = ? AND id = ?,防止越权删除他人备忘 MemoDAO.deleteByUserIdAndId(user.getId(), id); response.sendRedirect("list"); } }权限控制核心逻辑:
deleteByUserIdAndId()方法中 SQL 必须同时带user_id = ? AND id = ?,否则攻击者改 URL 中id=123就能删别人数据;listMemos()只查user.getId()的备忘,天然隔离。
5.3list.jsp页面:用 JSTL 循环展示,用is_done控制复选框状态与样式
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <h2>我的备忘录</h2> <a href="add">📝 添加新备忘</a> | <a href="logout">🚪 退出登录</a> <table border="1"> <tr><th>标题</th><th>内容</th><th>状态</th><th>操作</th></tr> <c:forEach items="${memos}" var="m"> <tr> <td>${m.title}</td> <td>${m.content}</td> <td> <c:choose> <c:when test="${m.isDone == 1}"> <span style="color:green">✅ 已完成</span> </c:when> <c:otherwise> <span style="color:gray">⏳ 进行中</span> </c:otherwise> </c:choose> </td> <td> <!-- 切换完成状态:GET 请求,无 CSRF 风险 --> <a href="toggleDone?id=${m.id}">🔄 ${m.isDone == 0 ? '标记完成' : '标记未完成'}</a> | <a href="edit?id=${m.id}">✏️ 编辑</a> | <a href="delete?id=${m.id}" onclick="return confirm('确定删除?')">🗑️ 删除</a> </td> </tr> </c:forEach> </table>玄学细节:
onclick="return confirm()":删除前二次确认,避免误点;toggleDone用 GET 是因为只是状态翻转,无数据修改,且简单;style="color:green"直接内联,教学项目不引 CSS,降低复杂度。
6. 避坑指南:5 个让 90% 新手卡住超过 2 小时的真实问题与血泪解法
6.1 现象:JSP 页面中文显示为乱码(如“备忘录”变成“???”),但数据库里存的是好的
原因:Tomcat 默认用 ISO-8859-1 解析 POST 请求体,而浏览器发的是 UTF-8 编码
解决:在web.xml中全局配置CharacterEncodingFilter,或在每个 Servlet 的doPost()开头加:
request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8");血泪经验:只设
response.setContentType不够,POST 表单提交必须request.setCharacterEncoding,否则getParameter()拿到的就是乱码。
6.2 现象:登录成功后跳转到index.jsp,但页面里${sessionScope.user.username}显示为空
原因:<%@ page contentType="text/html;charset=UTF-8" %>缺失,或 JSP 文件本身保存为 ANSI 编码(Windows 记事本默认)
解决:用 VS Code 或 Notepad++ 将所有.jsp文件另存为UTF-8 无 BOM格式,并确保每页顶部有:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>注意
pageEncoding是告诉 JSP 引擎“这个文件本身是 UTF-8 编码”,contentType是告诉浏览器“响应内容是 UTF-8”。
6.3 现象:MemoDAO.insert()执行后数据库没数据,但executeUpdate()返回 1
原因:MySQL 表引擎不是 InnoDB,而是 MyISAM(不支持事务,autocommit=true但实际不生效)
解决:建表时显式指定ENGINE=InnoDB,并检查:
SHOW CREATE TABLE memos; -- 输出中必须含 ENGINE=InnoDB翻车现场:某次用 Navicat 图形化建表,勾选了“支持事务”却忘了选引擎,结果死活不入库。
6.4 现象:BCrypt.checkpw()总返回 false,但hashpw()生成的串长度是对的
原因:checkpw()第一个参数是明文密码,第二个是数据库里存的加密串,顺序反了
解决:死记硬背口诀:“checkpw(plain,hashed)”,就像equals()一样,前者是待验证的,后者是基准
// ✅ 正确 if (BCrypt.checkpw("userInputPass", dbHashedPass)) { ... } // ❌ 错误(新手高频) if (BCrypt.checkpw(dbHashedPass, "userInputPass")) { ... }6.5 现象:response.sendRedirect("list")后页面 404,但http://localhost:8080/memo/list能直接访问
原因:sendRedirect()的路径是相对于域名的,不是相对于当前 Servlet 的路径
解决:用request.getContextPath()拼接:
response.sendRedirect(request.getContextPath() + "/memo/list");后悔药:开发时打开浏览器开发者工具 → Network 标签,看重定向的 Location 响应头,立刻知道路径拼错了没。
7. 进阶技巧:用 Log4j2 记录关键操作日志,用updated_at字段做乐观锁防并发覆盖
7.1 配置 Log4j2:替换System.out.println(),让调试有迹可循
在src/log4j2.xml中写:
<?xml version="1.0" encoding="UTF-8"?> <Configuration status="WARN"> <Appenders> <Console name="Console" target="SYSTEM_OUT"> <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> </Console> <File name="File" fileName="logs/memo.log"> <PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> </File> </Appenders> <Loggers> <Root level="info"> <AppenderRef ref="Console"/> <AppenderRef ref="File"/> </Root> </Loggers> </Configuration>在MemoServlet中使用:
private static final Logger logger = LogManager.getLogger(MemoServlet.class); private void updateMemo(HttpServletRequest request, HttpServletResponse response) { int id = Integer.parseInt(request.getParameter("id")); String title = request.getParameter("title"); String content = request.getParameter("content"); // ✅ 记录谁在什么时候改了哪条备忘 User user = (User) request.getSession().getAttribute("user"); logger.info("用户 {} (id={}) 更新备忘 id={},标题:{}", user.getUsername(), user.getId(), id, title); Memo memo = new Memo(); memo.setId(id); memo.setTitle(title); memo.setContent(content); MemoDAO.update(memo); response.sendRedirect("list"); }效果:logs/memo.log中会实时追加:
2024-06-15 14:22:33.123 [http-nio-8080-exec-1] INFO com.servlet.MemoServlet - 用户 admin (id=1) 更新备忘 id=5,标题:买牛奶7.2 用updated_at实现乐观锁:当两人同时编辑同一条备忘时,后提交者收到提示
修改MemoDAO.update()方法:
public static int update(Memo memo) { // 1. 先查出当前 updated_at String selectSql = "SELECT updated_at FROM memos WHERE id = ?"; Timestamp currentUpdatedAt = null; try (Connection conn = DBUtil.getConnection(); PreparedStatement selectStmt = conn.prepareStatement(selectSql)) { selectStmt.setInt(1, memo.getId()); try (ResultSet rs = selectStmt.executeQuery()) { if (rs.next()) { currentUpdatedAt = rs.getTimestamp("updated_at"); } } } catch (SQLException e) { e.printStackTrace(); return 0; } // 2. 带条件更新:只有 updated_at 没变才更新成功 String updateSql = "UPDATE memos SET title=?, content=?, updated_at=NOW() " + "WHERE id=? AND updated_at=?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(updateSql)) { pstmt.setString(1, memo.getTitle()); pstmt.setString(2, memo.getContent()); pstmt.setInt(3, memo.getId()); pstmt.setTimestamp(4, currentUpdatedAt); // ✅ 关键:只更新未被他人改过的记录 int rows = pstmt.executeUpdate(); if (rows == 0) { logger.warn("乐观锁失败:备忘 id={} 已被他人修改", memo.getId()); } return rows; } catch (SQLException e) { e.printStackTrace(); return 0; } }验证方法:
- 打开两个浏览器标签页,都编辑同一条备忘;
- A 先点保存 → 成功;
- B 后点保存 →
executeUpdate()返回 0,logger.warn输出警告,B 页面可弹窗提示“内容已被更新,请刷新后重试”。
7.3 一个我坚持了 5 年的习惯:每次git commit前,必跑一次mvn clean compile并手动启动 Tomcat 测试登录流程
不是信 CI/CD,而是信自己的手指。因为pom.xml里漏了个<scope>provided</scope>,或者web.xml少了个<servlet-mapping>,这些错误编译器不报,但 Tomcat 启动就炸。我见过太多人把ClassNotFoundException归咎于“jar 包冲突”,结果是WEB-INF/lib下压根没放mysql-connector-java.jar。所以我的本地工作流永远是:改代码 →mvn clean compile→cp target/*.war $TOMCAT/webapps/→sh $TOMCAT/bin/startup.sh→ 打开浏览器点三下:注册 → 登录 → 添加备忘 → 查看列表。只要这四步通,我就敢推。希望帮到你。
本文还有配套的精品资源,点击获取