Java EE 企业应用 E03:有状态会话、单例与定时器各保存什么
两名采购员看见同一份待办,定时任务却跑了两遍
采购员 A 在浏览器填写草稿,审批员 B 正查看全租户积压的采购申请;后台每五分钟扫描超时的审批任务。把这三种状态全放进一个 @ApplicationScoped 普通对象,至少出现三个错误:A 的暂存字段可能被另一个请求改写,单例的计数器没有自然的持久性,定时线程在应用重启时也不会凭内存继续。Enterprise Beans 提供有状态会话对象、单例受管对象和 Timer Service 等不同契约,但它们各自解决的范围不一样。数据库中的采购申请、租户和审批结果仍是权威业务记录,不能用一个 Bean 实例替代。
本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。
主线目前可核对的入口是 ProcurementUseCases 的 CDI 用例与 JdbcRequestStore 的显式 SQL。现有 server.xml 启用了 Jakarta EE 11 Platform,不能仅由特性配置推出 @Stateful、@Singleton 或持久化 Timer 在这个采购 WAR 上跑通;当前源码没有 E03 示范 bean。本章实验设计以隔离环境为限,真实状态、并发、定时触发及重启均 NOT_RUN。先修 04–05 负责解释受管对象与资源;18–19 负责事务入口与回滚;33 负责受管执行器的任务容量。缺了事务和业务持久化,任何“定时器已触发”的日志都不足以说明扫描动作正确。
有状态会话 Bean 是会话数据,不是订单数据库
对同一状态对象的两个并发请求也需要明确契约:浏览器可能双击“添加明细”,代理也可能重发请求。若两个调用指向同一 Stateful 实例,容器对该实例的并发访问可能排队或拒绝,但这不等于业务操作幂等;数量加两次仍会把订单扩大。应在进入采购用例前校验请求幂等键,并限制会话中暂存条目数量和总金额。不同 Stateful 实例之间没有共享字段,底层库存/预算数据仍可能并发变化;仅靠“会话中暂存了 20 件”不能证明下单时可用库存或当前报价。预览以本次会话状态展示,确认时必须在受管业务事务里按数据库事实重新验证。
如果客户端在添加明细后断线重连,会话关联是否仍在取决于 HTTP 会话、容器部署和关联策略;Bean 的会话身份不是无期限的订单查询键。丢弃对象时还要处理服务器端保留的业务相关敏感暂存项,不能仅让浏览器隐藏表单。测试 Stateful Bean 时应通过容器获取不同客户端引用,分别记录调用前后预览、移除后继续调用的实际异常,再用独立数据库查询确认没有因会话丢弃而回滚已提交行。单纯 new DraftSession() 只能验证 Java 对象字段,不能证明钝化、拦截或容器并发语义。
@Stateful 会话 Bean 的状态属于一个客户端关联的受管实例,适合很短的多步交互,例如“当前采购草稿的 SKU 清单”,而不是跨会话的订单事实。创建、调用、移除都应经容器代理;若单例 REST 资源把同一个状态 bean 引用传给所有请求,再贴上 @Stateful 也不能自动隔离客户端。浏览器会话与 Stateful 实例如何关联必须明确实现:会话映射清理、超时、部署更新与重试都可能失去引用。容器可选择钝化实例以回收内存;钝化前后的成员状态应满足适用的序列化/注入要求,不得把活的 Connection、HttpServletRequest 或调用方 Cookie 保存为会话字段。钝化可能发生不等于应用在本机已观察到钝化,更不等于跨服务器故障转移被保证。
下面的示意服务仅存可重建的 SKU 数量,并在用户确认时调用数据库用例;该 Bean 类并未加入现有源码,ProcurementUseCases 的参数签名需适配。真正决定订单能否重复创建的仍是业务幂等键、唯一约束和数据库事务,不能依赖 @Remove 的调用次数。
1 | |
这段代码没有保存审批者、租户或状态机,不可据此开放下单;同一客户端串行调用和不同客户端引用之间的差异需在实际容器里测。@Remove 结束的是受管会话,不会撤销先前已经提交到数据库的订单。客户端丢失会话后,应从受授权的采购详情查询重建页面,而不是到容器内存查“最后一次审批”。
单例的锁保护方法调用,不保护分布式账本
在正常对照中可以让两个调用线程分别进入读与写方法,记录被保护字段的版本和实际等待顺序;若只比较最终整数值,无法区分锁生效与恰好没有竞争。写方法更新缓存快照时,先构建新不可变数据,再在写锁保护下替换引用;读者要么看到旧快照,要么看到新快照,而不能看到更新一半的共享列表。缓存过期或加载数据库失败时,若返回上次成功的快照,必须给页面标明滞后策略;涉及审批额度时不能用此缓存越过当前数据库授权。把单例改成 @ApplicationScoped 而保留 @Lock 标签,也不意味着相同的容器并发契约,应按真实 Bean 类型检查拦截行为。
@Singleton 适合单个应用部署范围的缓存或任务协调入口;@Lock(READ) 可放行并发读取,@Lock(WRITE) 排他访问受管方法。这里的锁保护的是该 Bean 的访问路径,不是跨进程数据库行锁或所有副本的集群全局锁。若在读方法里修改可变列表,或从未加保护的方法泄露引用,不能因注解存在就称线程安全;等待锁时可由 @AccessTimeout 控制排队上限,超时需要调用方按异常处理而不能默认“操作已完成”。不同方法间再持有 JDBC 连接等待外部 HTTP,会把业务延迟放大成单例锁等待,应把状态尽量保持为不可变快照。
例如租户内待审批单计数可以由数据库聚合得到;把计数放在单例字段只能做可丢失的展示缓存。A 与 B 同时审批时,应以 UPDATE ... WHERE id=? AND tenant_id=? AND version=? AND status='SUBMITTED' 的受控行数判胜负,而非在内存锁下检查旧 DTO。若运行两个服务器实例,各有一个 @Singleton,计数可能短暂不同;要求全局一致时按数据库版本、失效事件与查询策略设计,不能寄希望于本地方法锁。
Timer 的触发与业务成功是两条时间线
定时扫描的窗口需要业务化:如果 10:00 应处理的申请直到 10:07 才被 Timer 唤醒,是按照当前时间补扫所有到期任务,还是只处理某个精确时点?通常任务查询应使用 due_at <= now 加稳定游标/批次上限,处理成功记录业务完成时间;不能因为错过一个周期就永远漏掉这批申请。服务端时区、数据库时区和运营展示时间分别冻结,夏令时边界不能单靠 @Schedule 的分钟表达式推断。超时任务如果正在另一个节点处理,扫描只能尝试原子领取而不能根据旧快照再发通知。
持久定时器和业务任务持久化仍分开看。Timer Service 恢复告诉容器需要重新触发回调,并不表示上次回调内的所有外部副作用自动回滚;反过来业务表里保留了到期任务,也不表示定时器实例已经在服务器中注册。重启实验需同时留存两种记录:定时器存储与回调触发时间、业务工作项的领取/完成行。将数据源关停一次,可区分“Timer 触发但业务连接失败”和“Timer 本身未恢复”;恢复后任务应按业务唯一键重试,而不是清空到期时间重新制造任务。
@Schedule 的自动定时器和 Timer Service 的程序式持久定时器提供容器管理的触发接口。持久属性与定时器存储、恢复策略须分别核对规范及发行版配置;persistent=true 不是“本次调用事务必定成功”,也不是“在任意宕机窗口恰好执行一次”。定时触发可能因进程重启晚到;任务执行中写入数据库后、容器完成对本次超时的记录前崩溃,恢复时可能重复调用。若扫描逻辑依赖逐笔订单或者 outbox,应先在数据库里原子领取工作项、限定租约/重试次数,用任务或事件 ID 去重,再在可观察账本记录处理结果。
1 | |
ExpiredApprovalService、到期规则、去重表及定时器存储都未实现;此片段不能独立编译。代码入口只是“每五分钟尝试扫描”,没有承诺精准触发于墙上时钟的某一秒。服务方法需明确事务边界:每次批量处理过多申请会使锁持有过久,失败时整体回滚;分批处理则需要检查部分成功、批次检查点与进度。部署更新可能重新建立定时器,必须检查是否有两个实例并行触发;如果定时器是程序式创建,启动时重复 create...Timer 还可能产生多个不同任务。外部扫描失败时,不得只看方法入口日志,须核对数据库每个任务的最终状态。
隔离实验怎样定位问题
有状态正常路径使用两个独立客户端,各创建一个草稿,分别加不同 SKU,检查各自预览不同;失败路径让一个客户端失去会话,检查另一个客户端不受影响、已提交申请仍在数据库且无法由新会话读取旧 Bean 内存。单例正常路径用受管方法并发读同一不可变快照;失败路径制造写操作占锁和读超时,记录调用完成/超时,不用普通 new ExpiredApprovalScanner() 冒充容器锁。Timer 正常路径在隔离容器留存触发日志与受控任务领取行;失败路径在领取后、完成前强制停隔离实例、恢复并检查重试是否只产生一次业务副作用。重启后有无持久定时器必须查定时器存储与本地记录,不能由 persistent=true 源码推断结果。若单例运行两个节点,还需重复并发实验才有跨节点结论。
**本工程没有这三类 Bean、可靠任务领取表或恢复证据;上述六条路径及多节点对照均 NOT_RUN。**正式执行需冻结容器补丁号、定时器后端与持久性配置,保留单次调用 ID、两个客户端会话标识的脱敏值、事务结束行与重启时间;不要误把既有 scenarios/08-session.sh 的 Servlet 会话行为算作 Stateful Bean 的验收。
Timer 方法中抛出异常的命运由受管事务、Timer 配置与发行版重试行为共同决定。测试不应只用 Thread.sleep 等一个周期然后说“肯定不会重复”,而要在副作用前、写入后、提交前分别注入故障并查询最终行。若采用程序式创建持久定时器,创建操作与唯一身份关系也要记录:启动监听器每次重启直接创建一个新 Timer,可能积累多个可重复触发的实例。对运行多个容器节点的场景,先查协调器/Timer 存储是否共享,再测并发触发;单机结果不能替代集群行为声明。
将三种组件放回一条采购链时,责任更清楚:Stateful 暂存客户端待提交明细,Singleton 持有可重建的只读统计或定时扫描协调入口,Timer 负责唤醒扫描,而 SQL 表的状态、租户和版本约束给出最终业务结果。如果把单例缓存的申请列表直接返回给客户端可修改的对象,锁只保护读取字段那一瞬间,客户端仍可能改变同一实例的内容;应返回副本并在业务提交前从数据库重查。Bean 生命周期错了,可能导致“看起来像多线程问题”的串租户故障,不能仅靠扩大线程池处理。
两道带答案的练习
练习一: Stateful Bean 保存 20 个 SKU,调用 discard() 后数据库的采购申请依然存在,是否违反有状态语义?
解: 不违反。Bean 保存客户端交互草稿,@Remove 管理 Bean 生命周期;数据库提交是一项独立事实。若产品要求取消已创建申请,需要显式业务状态迁移、权限与事务,不能由丢弃会话对象推断数据库回滚;当前没有实际实例运行。
练习二: 两个应用实例各有一个 @Singleton 与相同 @Schedule,都扫描到同一到期订单并发送通知。给单例方法加 @Lock(WRITE) 能杜绝重复吗?
解: 不能。各实例的锁不协调跨 JVM 操作。应以数据库任务唯一键和原子领取/幂等副作用控制,再记录两次扫描中哪个取得工作项、哪个返回 0 行;对定时器是否在集群只触发一个节点需独立冻结发行版行为,不能用注解猜。当前无双实例实验,状态 NOT_RUN。
版本资料与边界
目标运行组合为 JDK 21、Jakarta EE 11、Open Liberty 26.0.0.5。官方:Jakarta Enterprise Beans 4.0、Jakarta Transactions 2.0、Jakarta EE 11 Platform;发行版特性入口:Open Liberty 26.0.0.5 Jakarta EE 11。规范、平台特性清单和本应用在重启中的实际触发日志分别回答不同问题;上面代码不构成任何一项实测结论。





