17.2 服务器会话状态

在服务器系统中以序列化形式保存会话状态,由会话标识定位。

学习目标

  • 能实现一次订单向导请求的会话读写,让客户端只携带会话标识而不携带业务权威状态
  • 能解释服务器会话状态在序列化、过期、多节点访问和节点故障之间承担的责任
  • 能修改并排查会话代码,用证据判断应该保留服务器状态、改用集中存储,还是明确接受会话失效

为什么 17.2 服务器会话状态 值得单独学习

订单向导通常跨越地址、配送和付款确认多个请求。页面需要继续知道用户已经填写了什么,但每次请求又不应该把可篡改的购物车正文当成事实来源。把“谁拥有事实”这个决定摆到架构台面上:客户端只负责带回定位线索,服务器负责读写、授权、过期和恢复语义。

这不是“把对象塞进内存”这么简单。会话记录要有清楚的,存储层要能保存可恢复的版本,节点之间还要约定读取不到记录时是重新开始、转移到副本,还是向用户报告失效。若这些决定只藏在框架默认值里,正常请求通过也不等于架构完成。

本页以 2024 年中文版公开目录限定 17.2 服务器会话状态 的范围,并依据 Martin Fowler 的作者图书页和模式目录独立重写。下面的案例、实验、代码、判断题和答案均为本课程原创,不复现原书正文、插图或代码。

先建立直觉:状态到底由谁保管

先预测:如果请求只携带 sid=abc123,节点 A 读取服务器记录后把购物车数量从 2 更新为 3,下一次请求被路由到节点 B,哪一个对象必须能解释这次变化?答案应该是服务器端的会话存储,而不是 Cookie 中的一段未经保护的购物车正文。

服务器保存状态时,至少要定义四个边界:定位凭证是否可验证,资料是否能被,旧记录怎样按清理,以及多节点是否共享同一份权威记录。状态越敏感、越大或越需要跨请求一致,服务端边界越有价值;代价则是存储容量、网络往返、复制和恢复责任。

这里的“服务器端”不等于“某一台应用进程的内存”。单节点内存能快速返回,却会把扩缩容和故障恢复变成隐形的依赖。共享存储、复制内存或明确的丢失语义,才是多节点设计可以评审的选择。

目录单元到教学证据

17.2 服务器会话状态

17.2 服务器会话状态 的学习边界里,问题不是记住某个框架的 Session API,而是把“请求 → 会话标识 → 状态位置 → 更新 → 过期”责任链画出来。对订单向导会话,学习者应能指出谁保存事实、谁作业务决定,以及记录消失后结果是否可解释。

专属代码案例:订单向导的会话边界

下面的代码只展示模式边界,不绑定生产框架。会话存储接口返回服务器自己的记录;请求 DTO 只接收步骤输入,不能让客户端用整个对象覆盖存储记录。

type OrderSession = {
  id: string;
  version: number;
  cartItemCount: number;
  expiresAt: number;
};
 
type SessionStore = {
  load(id: string): Promise<OrderSession | null>;
  save(next: OrderSession, expectedVersion: number): Promise<void>;
};
 
export async function addItem(
  store: SessionStore,
  sessionId: string,
): Promise<OrderSession> {
  const current = await store.load(sessionId);
  if (!current || current.expiresAt <= Date.now()) throw new Error("session-expired");
  const next = { ...current, cartItemCount: current.cartItemCount + 1, version: current.version + 1 };
  await store.save(next, current.version);
  return next;
}

这段代码有两个故意的约束:sessionId 只用于定位,购物车数量从服务端当前版本计算;expectedVersion 让存储层可以拒绝基于旧记录的覆盖。实际系统还要把会话标识绑定到登录主体、轮换凭证,并限制单条记录大小。

主图把这条责任链拆成五个可观察事件。动手试:先点“下一步”预测记录怎样变化,再播放到过期和故障转移;随后打开错误模式,观察把 cart=999 放进请求正文为何越过了服务器权威边界。

可交互时序图先预测,再验证会话责任链
Server Session State:服务器保管状态,客户端只带标识定位、更新、过期与恢复都必须有可观察的责任边界请求时间线当前服务器状态请求:Cookie 只带 sid=abc123节点 A:验证 sid,读取服务器记录状态存储:写回 cart=3,并刷新 TTL清理器:TTL 到期,拒绝旧记录节点 B:从副本恢复,或返回明确失效会话存储sessions[abc123]当前值cart=3 · user=42服务端权威边界读写都记录节点与 sid不变量:请求只能定位状态,业务权威始终留在服务器失效、清理和故障转移必须返回可解释结果状态位置是架构决定:会话标识不是状态本身

第 1 / 5 步 · 请求携带 sid=abc123,不携带购物车正文

步点展示正常路径;错误模式把权威状态移入请求正文,便于对照失败证据。

当前:服务器拥有权威会话状态
服务器会话状态把业务资料留在服务端;真正需要验收的是定位、清理、多节点访问和节点故障后的结果。

三步快照:从定位到恢复

下面的 Stepper 将主图压缩为三张确定性快照。每一步都嵌入同一章专属 Diagram,方便在暂停状态下核对对象、责任和失败语义。

分步1 / 3

1. 请求只带定位线索

先预测:请求中只有 sid 时,节点应从哪里取得 cartItemCount?它只能从服务器端记录读取,不能把请求正文当成权威快照。

Server Session State:服务器保管状态,客户端只带标识定位、更新、过期与恢复都必须有可观察的责任边界请求时间线当前服务器状态请求:Cookie 只带 sid=abc123节点 A:验证 sid,读取服务器记录状态存储:写回 cart=3,并刷新 TTL清理器:TTL 到期,拒绝旧记录节点 B:从副本恢复,或返回明确失效会话存储sessions[abc123]当前值cart=3 · user=42服务端权威边界读写都记录节点与 sid不变量:请求只能定位状态,业务权威始终留在服务器失效、清理和故障转移必须返回可解释结果状态位置是架构决定:会话标识不是状态本身
服务器会话状态把业务资料留在服务端;真正需要验收的是定位、清理、多节点访问和节点故障后的结果。

多节点、过期与恢复:把隐性代价写出来

单节点内存适合短生命周期、低恢复要求的临时向导,但它会把会话定位绑定到节点。加负载均衡后,常见路线有三种:使用副本,改用集中式会话存储,或明确接受节点故障导致重新开始。选择哪一条,取决于会话资料是否可重建、用户能否接受丢失,以及复制成本是否低于恢复收益。

过期不是一次定时删除就结束。读取路径必须拒绝过期记录,后台清理器负责回收容量,续期规则要防止活跃用户在中途被误删;写入路径还要防止旧请求在续期之后把新版本覆盖。记录 sessionId、版本、节点、过期时间和失败原因,才能把“用户突然回到第一步”定位成超时、路由、复制还是授权问题。

如果状态包含付款意图、优惠资格或库存承诺,就不能只因为框架提供了 Session 就把它当成最终业务事实。会话可以保存向导草稿;提交订单时仍应重新读取关键业务数据,在事务边界内完成校验和写入。会话状态解决的是跨请求上下文,不自动解决业务一致性。

代码实践:把客户端输入限制在步骤增量

下面的处理器只接受当前步骤的增量字段,并让存储层负责版本冲突。它可以作为练习的起点:先让它返回 session-expired,再分别模拟节点不可达和版本冲突,观察调用方是否得到不同的恢复提示。

type StepInput = { address?: string; paymentToken?: string };
type SessionResult =
  | { ok: true; session: OrderSession }
  | { ok: false; reason: "expired" | "conflict" | "unavailable" };
 
async function updateWizard(
  store: SessionStore,
  sessionId: string,
  input: StepInput,
): Promise<SessionResult> {
  try {
    const current = await store.load(sessionId);
    if (!current || current.expiresAt <= Date.now()) return { ok: false, reason: "expired" };
    const next = { ...current, version: current.version + 1 };
    await store.save(next, current.version);
    return { ok: true, session: next };
  } catch (error) {
    if (error instanceof Error && error.message === "version-conflict") return { ok: false, reason: "conflict" };
    return { ok: false, reason: "unavailable" };
  }
}

这里 input 只代表步骤增量;生产实现需要把地址和支付令牌校验后合并到服务器记录,并明确支付令牌的保管方式。若 store.loadstore.save 跨越不同节点,存储实现还必须提供原子条件写或等价的并发控制。

选择与拒绝矩阵

评审问题保留服务器会话状态的证据应拒绝或改用其他方案的信号
权威位置Cookie 只有可验证的会话标识,业务资料由服务端读取客户端正文可以覆盖价格、权限或支付状态
节点变化共享记录、复制策略或明确的丢失语义已经演练只能靠粘性会话,节点故障后没有恢复或提示
过期清理读取拒绝、后台清理、续期和容量指标都有证据只在写入时检查 TTL,过期记录持续占满存储
业务提交会话保存草稿,提交时重新校验关键业务事实把会话记录当成库存、付款或授权的最终事实

常见误区

本章小结

  • 会话标识只负责定位,服务器记录才是业务草稿的权威来源。
  • 序列化、版本和条件写共同保护跨请求更新。
  • 多节点设计必须说明共享、复制或明确丢失语义。
  • TTL 同时需要读取拒绝、后台清理、续期和容量观测。
  • 提交订单时要重新校验关键业务事实,不能盲信会话草稿。

练习与答案

练习

问题 1:实现边界。 POST /wizard/step 同时收到 sessionIdaddresscartItemCount。请写出服务端应接受的字段,并指出为什么不能直接采用客户端的 cartItemCount

问题 2:故障演练。 节点 A 的内存会话在重启后消失,下一次请求到达节点 B。请比较“改用共享存储”和“明确会话失效”两种结论各自需要的证据。

问题 3:改 Demo 代码。 修改 updateWizard,让 store.save 收到 current.version 作为条件,并把 version-conflict 映射成用户可以重试当前步骤的结果。你还会为哪个状态加测试?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

服务器会话状态
跨请求资料保存在服务器端,客户端只带能找到这份资料的线索;它适合不希望把业务正文交给客户端的向导。
会话标识
定位一次会话的短键,例如 sid=abc123;它本身不应等同于购物车或付款资料。
序列化
把内存中的对象转换成可以保存或传输的格式,读取时还要检查版本和安全边界。
TTL
Time to Live 的缩写,表示记录最多能活多久;到期后读取必须失败,清理器再回收空间。
粘性会话
把用户请求固定送到某个节点的路由办法;它减少共享存储需求,却把节点故障风险留在内存里。
故障转移
原节点不可用时由副本或其他节点继续提供会话,或者明确返回会话失效;关键是结果必须可解释。

来源与改写范围

资料与写作方式声明

本章以Martin Fowler《企业应用架构模式》权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

前后导航

讨论

评论区加载中…