SSH四件套整合实战:Spring Security与Hibernate事务懒加载避坑指南 简介基于Spring Security、Spring MVC、Spring和Hibernate整合的权限管理系统项目源码面向Java后端开发者重点演示SSH框架下用户认证、授权及角色-资源管理的实现方式。整个压缩包共196个文件约18.66MB包含60个jar依赖库、34个Java源文件及对应class文件同时提供JSP页面、XML配置、SQL脚本、CSS与JavaScript等前端资源便于直接导入IDE运行与调试。该资源已有283人学习浏览适合作为课程设计、毕业设计或企业项目中的权限管理模块参考。项目实现了完整的用户登录认证与权限拦截流程通过SecurityMetadataSource动态加载资源AccessDecisionManager进行访问决策支持用户-角色-资源三级关联管理并利用Hibernate完成ORM持久化。结合Spring的依赖注入与AOP事务管理整体代码模块划分明确能够帮助开发者快速理解SSH整合的关键配置与权限控制核心逻辑便于二次开发与功能扩展。1. SSH 安全四件套为什么老系统改造时最头疼的就是它springsecurityspringmvcspringhibernate 这套组合在教程里已经很少见了但存量系统改造时反而绕不开。我这两年接手过的项目里至少有三分之一还在跑类似结构最麻烦的安全部分往往要整个重写登录、会话、细粒度权限几乎每个老系统都要过一遍。这套整合的难点在于三者互相牵扯——Spring Security 的过滤器链、Hibernate 的懒加载、Spring 的事务边界会互相影响改一处另一处就翻车。这份资源把四件套串成一个可运行的完整工程从 web.xml 装配、会话工厂加载到认证授权与事务优化每一步都带对应配置和可复制代码。适合要接手 SSH 存量系统的开发者也适合想把 Spring MVC Hibernate Spring Security 整合逻辑一次理顺的从业者。下面统一叫它 SSH 四件套。2. Spring 与 Hibernate 握手会话工厂装配与实体映射的关键配置这一章先把地基打牢。Hibernate 会话工厂装配得好不好直接决定后面 Spring Security 的 UserDetailsService 和 Service 层事务能不能安稳跑起来。2.1 装配一个真正可用的 SessionFactory四个必填参数Hibernate 单独使用时要自己写 Configuration().configure()最后再 buildSessionFactory()。一旦放进 Spring 容器这套手动流程就交给 LocalSessionFactoryBean 管理。它的作用不是替代 Hibernate 的 Configuration而是把数据源、方言、实体扫描路径、参数等统一从 XML 注入由 Spring 决定会话工厂的创建与销毁时机。常见做法是在 applicationContext-dao.xml 里这样配置bean iddataSource classorg.apache.commons.dbcp2.BasicDataSource destroy-methodclose property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/sshdemo?useUnicodetrueamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword value123456/ property nameinitialSize value5/ property namemaxTotal value20/ /bean bean idsessionFactory classorg.springframework.orm.hibernate5.LocalSessionFactoryBean property namedataSource refdataSource/ property namepackagesToScan valuecom.demo.entity/ property namehibernateProperties props prop keyhibernate.dialectorg.hibernate.dialect.MySQL5Dialect/prop prop keyhibernate.show_sqltrue/prop prop keyhibernate.format_sqltrue/prop prop keyhibernate.hbm2ddl.autoupdate/prop prop keyhibernate.current_session_context_classorg.springframework.orm.hibernate5.SpringSessionContext/prop /props /property /bean先解释这段配置的作用顺序Spring 先创建数据源再把数据源交给 LocalSessionFactoryBean最后扫描 com.demo.entity 下的实体类按方言生成与 MySQL 对应的 SQL 方言实现。整个过程中 Spring 负责管理 SessionFactory 的生命周期应用重启时容器会负责释放旧连接。四个关键参数单独挑出来说。hibernate.dialect 决定分页语法、主键生成策略和字段类型映射MySQL 5 用 MySQL5DialectOracle 11g 用 Oracle10gDialect。这里容易踩坑的是库表大小写Linux 下 MySQL 表名默认大小写敏感方言本身解决不了这个问题要配合后文的命名策略一起处理。hibernate.show_sql 建议开发环境开、生产关format_sql 打开后至少能看出 Hibernate 生成的 SQL 是两行还是二十行省得排查问题全靠猜。hbm2ddl.auto 是双刃剑update 模式适合开发但它只增不改、只建不删别指望它做表结构变更生产环境我更习惯用 validate让启动时校验实体和表结构是否对得上。最后一行 current_session_context_class 单独讲。这个属性把 Hibernate 的 Session 上下文绑定到 Spring 的事务同步管理器上这样一来 getCurrentSession() 拿到的就是事务内部那个 Session它会在事务提交时自动 flush 并释放。如果漏掉这行最常见报错就是 No Session bound to thread后面避坑章还会专门讲到。2.2 实体映射与命名策略Java 驼峰与数据库下划线之间没有巧合实体类表面上只是加注解实际决定了一张表的名称、字段类型、约束和关联关系能否如预期生成。我见过不少项目直接把 Java 类的字段名拿去做列名结果数据库里全是 username、createDate 这种大小写混写换到 PostgreSQL 或 Linux 的 MySQL 上查询时连引号都要纠结半天。推荐写法是显式指定 Table 和 Column不要依赖 Hibernate 自动推断。示例Entity Table(name sys_user) public class SysUser implements Serializable { Id GeneratedValue(strategy GenerationType.IDENTITY) Column(name id) private Long id; Column(name username, nullable false, unique true, length 50) private String username; Column(name password, length 100, nullable false) private String password; Column(name enabled, nullable false) private Boolean enabled true; ManyToMany(fetch FetchType.EAGER) JoinTable(name sys_user_role, joinColumns JoinColumn(name user_id), inverseJoinColumns JoinColumn(name role_id)) private ListSysRole roles new ArrayList(); }这个实体直接服务于 Spring Security 的用户加载逻辑。Column 的 nullable、unique、length 会同步反映到 hbm2ddl 生成的建表语句里所以约束最好一次写对别让 Hibernate 去猜。ManyToMany 这里选择 FetchType.EAGER是因为 Spring Security 登录认证成功后需要立刻拿到角色列表如果在认证流程中触发懒加载又要额外处理 Session 边界很容易把问题搞复杂。命名策略方面Spring 整合 Hibernate 时默认会走驼峰转下划线规则也就是 createDate 自动映射为 create_date。如果你的数据库表是老同事按 Java 字段名建的每个列都带大小写反而要禁用这种自动转换property namehibernate.physical_naming_strategy org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl /property参数说明PhysicalNamingStrategyStandardImpl 表示不转换物理列名实体里写什么列名SQL 里就用什么。另一个常见配置是 SpringPhysicalNamingStrategy会把列名统一成小写下划线。这个选择没有绝对对错但一定要和数据库现有表的命名风格一致否则启动时 validate 就会报一堆列找不到。2.3 DAO 层写法别再用 HibernateTemplate直接用 getCurrentSession早期 SSH 教程里大量出现 HibernateTemplate 和 HibernateDaoSupport这两个东西借助 Spring 的回调机制帮忙管理 Session 开关。问题是代码被套成了模板模式查一条记录也要包一层回调一旦出现嵌套调用事务边界和异常转换反而更难判断。在普通的 Repository Bean 里直接注入 SessionFactory 就行Repository public class SysUserDao { Autowired private SessionFactory sessionFactory; public SysUser findByUsername(String username) { String hql from SysUser where username :username; QuerySysUser query sessionFactory.getCurrentSession() .createQuery(hql, SysUser.class); query.setParameter(username, username); return query.uniqueResult(); } }getCurrentSession() 的前提是当前线程已经处于 Spring 事务上下文中这个 Session 由事务管理器拦截器负责关闭不需要手动 close。相比之下 openSession() 会创建一个与事务无关的临时 Session用完必须自己处理异常和关闭稍一疏忽就是连接泄漏。findByUsername 里的 createQuery(hql, SysUser.class) 是 Hibernate 5.2 之后带实体类型的重载省去了强转。查询里不用写 select *直接 from SysUser 即可Hibernate 会自动映射字段。参数绑定统一用 :username 加 setParameter一方面防止拼接注入另一方面也便于 Hibernate 做 SQL 计划缓存。到这里DAO 层与 SessionFactory 的关系就打通了。下一步要解决的是入口问题外部请求怎么安全地进来认证和权限规则放在哪一层这是第 3 章的内容。3. Spring MVC 接收请求、Spring Security 守住入口认证与授权落地3.1 过滤器链如何拦截每一个请求SSH 整合里常见的困惑是Security 配置写在 security.xml它怎么知道要拦截 /login、/admin/** 这些 URL原因是 web.xml 里注册了 springSecurityFilterChain 过滤器它本身是一个 DelegatingFilterProxy把过滤逻辑委托给 Spring 容器里名为 springSecurityFilterChain 的过滤器链 Bean。web.xml 的关键配置context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping filter filter-namespringSecurityFilterChain/filter-name filter-classorg.springframework.web.filter.DelegatingFilterProxy/filter-class /filter filter-mapping filter-namespringSecurityFilterChain/filter-name url-pattern/*/url-pattern dispatcherREQUEST/dispatcher /filter-mapping这里的过滤顺序值得注意。如果后面加了 OpenSessionInViewFilter尽量让它在 springSecurityFilterChain 之前因为认证过程要查数据库如果 Session 还没绑定到当前线程UserDetailsService 里的查询也会碰上懒加载问题。参数说明DispatcherServlet 的 url-pattern 配成 / 表示接管所有非静态资源请求静态资源要单独放行。springSecurityFilterChain 的 url-pattern 用 /*包含所有请求路径登录成功与失败的跳转由过滤器链内部处理不需要在 Controller 里手写会话判断。开启过滤器链后请求流是这样请求进 Servlet 容器被 springSecurityFilterChain 拦截未认证则跳转登录页或返回 401已认证则继续放行到 DispatcherServlet再按 Controller 匹配。这个过程对调用方是透明的但对调试很有用登录失败后先看过滤器链有没有短路再看 Controller 有没有执行。3.2 登录认证UserDetailsService、数据库表设计与 BCrypt 密码Spring Security 不需要依赖固定表结构只要实现 UserDetailsService 接口从自己的表里把用户和角色捞出来组装成 UserDetails 交给认证管理器即可。安全模块只关心两件事用户名是否存在、密码是否匹配。我一般会在 security.xml 里做如下配置security:http auto-configfalse use-expressionstrue security:intercept-url pattern/login accesspermitAll/ security:intercept-url pattern/css/** accesspermitAll/ security:intercept-url pattern/js/** accesspermitAll/ security:intercept-url pattern/admin/** accesshasRole(ADMIN)/ security:intercept-url pattern/** accessisAuthenticated()/ security:form-login login-page/login default-target-url/index authentication-failure-url/login?errortrue/ security:logout logout-url/logout logout-success-url/login?logouttrue delete-cookiesJSESSIONID/ /security:http security:authentication-manager security:authentication-provider user-service-refuserDetailsService security:password-encoder refpasswordEncoder/ /security:authentication-provider /security:authentication-manager bean idpasswordEncoder classorg.springframework.security.crypto.bcrypt.BCryptPasswordEncoder/use-expressionstrue 允许在 access 里写 hasRole、hasAuthority、isAuthenticated 这类表达式。login-page 指向的是 Spring MVC 的 /login 控制器这个页面本身必须 permitAll否则用户还没登录就进不去登录页。default-target-url 是登录成功后跳转的地址如果需要记录用户原本想访问的页面可以借助 savedRequest 机制这里不再展开。UserDetailsService 的实现如下Service public class SysUserDetailsService implements UserDetailsService { Autowired private SysUserDao sysUserDao; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user sysUserDao.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在: username); } ListGrantedAuthority authorities new ArrayList(); if (user.getRoles() ! null) { for (SysRole role : user.getRoles()) { authorities.add(new SimpleGrantedAuthority(ROLE_ role.getRoleName())); } } return new User(user.getUsername(), user.getPassword(), authorities); } }两个细节需要说明。第一角色前缀 ROLE_ 要在这里拼好因为后面的 hasRole(ADMIN) 实际匹配的是 ROLE_ADMIN。如果库里存的是 ADMIN、代码里写的也是 ADMIN但 hasRole 始终不通过多半就是前缀没统一。第二authorities 里的内容会进入 SecurityContextController 里可以通过 Authentication 对象直接读取不要在 Service 层反复查库取角色。密码加密这块老项目最常见的错误是数据库里存明文然后用自定义逻辑做解密。正确做法是存 BCrypt 密文匹配时由 BCryptPasswordEncoder 的 matches 方法完成。数据库密码字段长度至少要 60因为 BCrypt 输出固定 60 字符。3.3 授权模型URL 规则与方法级安全一起用只配置 URL 拦截规则权限判断容易集中在 Controller 层复用性差。配合方法级安全把权限放在 Service 方法上是更稳的组合。开启方式是在 security.xml 里加security:global-method-security pre-post-annotationsenabled/这样就能在 Service 方法上写 PreAuthorizePreAuthorize(hasRole(ADMIN)) public void deleteUser(Long id) { // 删除用户逻辑 }URL 规则解决“能访问哪个页面”方法级权限解决“能操作哪个动作”。两者不一致时以更严格的一侧为准。实际项目里经常出现菜单已经隐藏了某个按钮但接口仍能被直接调用的情况方法级权限就是最后的防线。这里给一张常用权限表达式对照表表达式含义典型位置permitAll不要求认证URL 规则登录页/静态资源isAuthenticated()已登录可访问URL 规则默认值hasRole(ADMIN)拥有 ROLE_ADMIN 角色URL/方法级hasAuthority(user:delete)拥有某个具体权限码方法级hasAnyRole(ADMIN,AUDITOR)多个角色任一即可方法级拥有角色和拥有权限码是两种建模思路。角色适合粗粒度权限码适合细粒度如果系统需求已经细到“导出报表”和“删除报表”要分开授权建议改用 hasAuthority角色只作为权限码集合的逻辑分组。4. 事务边界与懒加载跨层调用最容易翻车的两个点4.1 事务要放在 Service注解与代理的生效条件DAO 层直接使用 getCurrentSession就要求外层必须有事务那事务应该放在哪一层标准做法是放在 Service 实现类的方法上因为一个业务操作通常要跨多个 DAO 方法比如创建用户时要插入用户表、插入用户角色关系表这两步必须同生共死。事务配置如下bean idtransactionManager classorg.springframework.orm.hibernate5.HibernateTransactionManager property namesessionFactory refsessionFactory/ /bean tx:annotation-driven transaction-managertransactionManager/Service 方法上标注 Transactional 后Spring 会为类生成代理代理在方法进入前开启事务方法正常返回后提交抛出 RuntimeException 时回滚。Service public class SysUserServiceImpl implements SysUserService { Transactional(rollbackFor Exception.class, readOnly false) public void createUserWithRoles(SysUser user, ListLong roleIds) { sysUserDao.save(user); for (Long roleId : roleIds) { sysUserRoleDao.saveRelation(user.getId(), roleId); } } Transactional(readOnly true) public ListSysUser listUsers(Integer pageNo, Integer pageSize) { return sysUserDao.findPage(pageNo, pageSize); } }两个参数值得单独说明。rollbackFor Exception.class 是把受检异常也纳入回滚范围因为 Spring 默认只在 RuntimeException 和 Error 时回滚如果业务代码里抛出业务自定义的 Exception不加这个参数就会提交一个半成品事务。readOnly true 是给数据库和 Hibernate 的查询优化提示它不会真的阻止写操作但能让 Session 的 flush 模式变慢减少无意义的脏数据检查。4.2 LazyInitializationException发生在哪里、怎么处理Hibernate 的实体关联默认都是懒加载ManyToOne、OneToMany 只有在属性被访问时才发 SQL 查询。这本身是性能优化但跨层调用时有个致命问题Service 里查询出来的实体到 Controller 层或视图模板里访问它的集合属性时Session 可能已经关了于是抛 LazyInitializationException。最常见的报错现场有两种Controller 把查询到的 User 对象直接传给视图JSP 里写 user.roles.size渲染时崩溃或者把实体直接转 JSON 返回给前端序列化 roles 属性时抛异常。解决思路有两条线。第一条线是打开 OpenSessionInViewFilter把 Session 生命周期拉长到整个请求filter filter-nameopenSessionInView/filter-name filter-classorg.springframework.orm.hibernate5.support.OpenSessionInViewFilter/filter-class /filter filter-mapping filter-nameopenSessionInView/filter-name url-pattern/*/url-pattern /filter-mapping这个过滤器会把 Spring 管理的 Session 绑定到当前线程直到视图渲染完成才关闭。优点是改动小、见效快代价是数据库连接被占用更久连接池如果太小并发一高就会打满。生产系统不建议无脑挂这个过滤器请想清楚自己的并发量再决定。第二条线是 DTO 转换。在 Service 内把需要的数据查全并拷贝到 DTOController 只面向 DTO 编程。比如用户列表页只需要 userId、username、roleNames 三个字段就写一个专用查询方法Transactional(readOnly true) public ListUserListDTO listUserWithRoles(int pageNo, int pageSize) { String hql select new com.demo.dto.UserListDTO(u.id, u.username, r.roleName) from SysUser u join u.roles r order by u.id desc; return sessionFactory.getCurrentSession() .createQuery(hql, UserListDTO.class) .setFirstResult((pageNo - 1) * pageSize) .setMaxResults(pageSize) .list(); }关键在 join u.roles 这个内连接它已经在 SQL 层把角色查出来了Hibernate 返回的 DTO 里只有三个标量字段彻底避开集合懒加载。DTO 构造器参数顺序要与 select 子句一致字段类型和返回值类型要固定。4.3 事务与懒加载一起用getCurrentSession 的必要前提把前面的内容汇到一起看getCurrentSession 能用前提是当前线程已有 Spring 事务上下文。如果你在 Controller 里直接调 DAO 的 findById而 Controller 没加 TransactionalDAO 的 getCurrentSession 就会抛异常。这不是 DAO 写错了而是事务边界没遵守分层约定。常规做法是Controller 不碰事务所有业务逻辑进 ServiceService 的每个方法成为事务边界DAO 只做数据访问。如果方法内部确实需要先查询再更新也都在同一个事务内完成比如先 getUser 再更新 role 集合事务会把这两步包成一个整体。提示如果你在 Controller 里看到自己手动调用了 sessionFactory.getCurrentSession()基本可以判定事务分层已经乱套了。重构时先把 Controller 里的数据访问代码挪到 Service再加上 Transactional 注解很多运行期怪问题会不治而愈。5. 避坑指南SSH 整合中我踩过的 5 个运行时问题这一章把工程中最常见的几个运行期问题按现象、原因、解决的顺序列出来每条都值得对照自己的项目查一遍。5.1 hbm2ddl.autoupdate 不建表也不更新字段现象项目启动不报错但数据库里没有表或者新增实体字段后 update 模式没有生成新列。原因dialect 配置不正确、账号没有 DDL 权限或者实体类没有被 packagesToScan 扫描到。还有一种隐蔽情况——数据库连接的是 A 库但配置文件里写的是 B 库的 URL表自然建到了 B。解决启动时打开 show_sql观察有没有生成建表语句检查数据库 URL、账号权限确认实体类所在的包确实在 packagesToScan 里。开发环境可以用 validate 模式做一次校验让缺列缺表在启动阶段就暴露出来。我在一个新项目中遇到过 update 失效排查到最后发现是 Maven 打出来的 jar 里没把实体类带进去连 ClassNotFoundException 都没报只是静默扫描不到。5.2 getCurrentSession 报 “No Session bound to thread”现象Service 方法上没加 TransactionalDAO 里调用 getCurrentSession() 抛异常或者加了注解仍然报错。原因缺少 hibernate.current_session_context_classSpringSessionContext或事务管理器没有交给 Spring 管理或 Service 方法不是通过代理调用的。解决按顺序检查 SessionFactory 的 hibernateProperties 中是否配置了 SpringSessionContext检查 Spring 配置文件里有没有注册 HibernateTransactionManager检查 Service 实现类是否被 Spring 扫描到进入方法时在 IDE 里确认 AopProxy 有生效。如果是在工具类或定时任务里手动调用 DAO也要确保调用入口上有事务注解。5.3 Transactional 没有生效、没有回滚现象代码里写了 rollbackFor Exception.class但异常抛出后数据还是入库了。原因最常见的是自调用即同一类的 A 方法调用 this.B 方法B 上的注解代理逻辑根本不会执行。其次是方法声明为 privateSpring 的 CGLIB 代理无法介入。还有一种情况是 try-catch 把异常吞掉事务管理器感知不到异常。解决自调用拆到不同 Bean 里比如把 B 放到另一个 Service 类中通过注入的对象调用private 方法改成 public异常要抛出去不要用 try-catch 吃掉后再返回一个错误码。这条规则也同样适用于 PreAuthorize 之类的方法级权限注解代理失效时权限检查也会一并失效。5.4 用户密码正确但 Spring Security 一直提示 Bad credentials现象数据库里密码已经存了密文用户名也对登录始终失败。原因最可能是密码编码器没配置或配置不一致。比如数据库是用 MD5 生成的密文而 security.xml 里配的是 BCryptPasswordEncoder或者 authentication-provider 没有指定 password-encoderSecurity 默认按明文比对。解决统一密码算法。存量数据如果是 MD5一种做法是把数据库历史密文切换为 BCrypt登录成功后把明文密码转换成 BCrypt 再回写另一种做法是暂时保留 MD5 并提供对应编码器同时做双编码兼容。我一般建议直接迁移到 BCryptMD5 作为凭证存储算法在安全场景下已经不够用。排查时可以临时打印传入的 password 和存储的 password 前后缀确认不是配置了两次编码器导致二次哈希。5.5 返回 JSON 时一直报 LazyInitializationException现象接口返回用户列表Jackson 序列化时提示无法初始化 roles 集合属性。原因实体里的 ManyToMany 设置为懒加载返回 JSON 时 Session 已经关闭序列化器会尝试加载所有关联属性从而触发异常。解决两层配套处理。实体上给需要序列化的关联属性标注 JsonIgnore或配置 Hibernate5Module 让它忽略无法加载的懒加载属性业务层改用 DTO 输出只暴露需要返回的字段。查询时如果确实需要关联数据就用第 4.2 节的 join 查询而不是把整个实体直接往外丢。顺带提一句开启 OpenSessionInViewFilter 只能解决页面渲染场景对 JSON 序列化同样有效但代价是连接占用变长要评估连接池容量再决定。6. 进阶技巧把安全审计与分页查询打磨顺手登录记录是很多管理系统的隐性需求。把 Spring Security 的 AuthenticationSuccessEvent 监听起来就能在不侵入原有登录流程的前提下记录每次成功登录。Component public class LoginSuccessListener implements ApplicationListenerAuthenticationSuccessEvent { Autowired private OperationLogDao operationLogDao; Override public void onApplicationEvent(AuthenticationSuccessEvent event) { String username event.getAuthentication().getName(); Object details event.getAuthentication().getDetails(); String ip ; if (details instanceof WebAuthenticationDetails) { ip ((WebAuthenticationDetails) details).getRemoteAddress(); } operationLogDao.save(new OperationLog(username, LOGIN_SUCCESS, ip, new Date())); } }这里从 WebAuthenticationDetails 里取 IP 是最常见做法它由表单登录的过滤器写入 authentication details。如果部署在反向代理后面取到的可能是代理地址需要再结合 X-Forwarded-For 头处理。监听器里不要做耗时操作写日志的 DAO 操作建议走独立事务或异步线程避免阻塞认证响应。分页查询也是 Hibernate 项目里反复要写的东西。数量统计和列表查询往往是两条 SQL但很少有人解释为什么要拆开。因为 count 和 list 的返回结构不同一条查询只能选一种结果类型。下面这段是 HQL 分页的基础写法Transactional(readOnly true) public PageResultCourse findCoursePage(int pageNo, int pageSize) { Session session sessionFactory.getCurrentSession(); QueryCourse query session.createQuery( from Course c where c.status :status order by c.createTime desc, Course.class); query.setParameter(status, 1); query.setFirstResult((pageNo - 1) * pageSize); query.setMaxResults(pageSize); ListCourse rows query.list(); Long total session.createQuery( select count(c) from Course c where c.status :status, Long.class) .setParameter(status, 1) .uniqueResult(); return new PageResult(rows, total.intValue()); }setFirstResult 与 setMaxResults 配合方言生成对应的 limit 语句页码从 1 开始第一页偏移量是 0。pageSize 建议放在常量类里统一管理避免每个方法写死一个 10 或 20。统计查询里写 select count(c) 而不是 count(*)便于 Hibernate 把 count 语句优化成单列计数。Hibernate 5.2 及以上版本里列表查询统一用 query.list()如果工程升级到较新的 Hibernate 6签名会变成 getResultList()迁移时留意这一处即可。这套 SSH 四件套整合做完一遍我最大的教训是配置文件的顺序和职责边界比任何一行代码都重要。从那以后我每次接手老系统都强制先画一遍 web.xml 的过滤器顺序、事务边界和懒加载触发点再动手改代码很多半夜才能查完的“玄学问题”其实都只是分层没理清。拿到这份工程后按这个顺序把配置文件和实体类逐一过一遍应该能少走不少弯路。希望这些经验能帮到你。本文还有配套的精品资源点击获取