一、 架构演进背景:传统Session的集群瓶颈与Redis破局
1.1 单体架构下的状态管理局限
在传统单体部署模式中,用户登录后的身份信息通常强绑定于服务端本地内存。用户完成认证后,服务端将用户对象存入当前进程的Session空间,并通过HTTP响应头下发SessionId至客户端Cookie。后续请求携带该Cookie到达服务端时,拦截器从本地Session中提取用户数据,注入线程上下文后放行。
#mermaid-svg-vZGtcrs9BdUlBEHm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vZGtcrs9BdUlBEHm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vZGtcrs9BdUlBEHm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vZGtcrs9BdUlBEHm .error-icon{fill:#552222;}#mermaid-svg-vZGtcrs9BdUlBEHm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vZGtcrs9BdUlBEHm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vZGtcrs9BdUlBEHm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vZGtcrs9BdUlBEHm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vZGtcrs9BdUlBEHm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vZGtcrs9BdUlBEHm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vZGtcrs9BdUlBEHm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vZGtcrs9BdUlBEHm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vZGtcrs9BdUlBEHm .marker.cross{stroke:#333333;}#mermaid-svg-vZGtcrs9BdUlBEHm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vZGtcrs9BdUlBEHm p{margin:0;}#mermaid-svg-vZGtcrs9BdUlBEHm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vZGtcrs9BdUlBEHm .cluster-label text{fill:#333;}#mermaid-svg-vZGtcrs9BdUlBEHm .cluster-label span{color:#333;}#mermaid-svg-vZGtcrs9BdUlBEHm .cluster-label span p{background-color:transparent;}#mermaid-svg-vZGtcrs9BdUlBEHm .label text,#mermaid-svg-vZGtcrs9BdUlBEHm span{fill:#333;color:#333;}#mermaid-svg-vZGtcrs9BdUlBEHm .node rect,#mermaid-svg-vZGtcrs9BdUlBEHm .node circle,#mermaid-svg-vZGtcrs9BdUlBEHm .node ellipse,#mermaid-svg-vZGtcrs9BdUlBEHm .node polygon,#mermaid-svg-vZGtcrs9BdUlBEHm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vZGtcrs9BdUlBEHm .rough-node .label text,#mermaid-svg-vZGtcrs9BdUlBEHm .node .label text,#mermaid-svg-vZGtcrs9BdUlBEHm .image-shape .label,#mermaid-svg-vZGtcrs9BdUlBEHm .icon-shape .label{text-anchor:middle;}#mermaid-svg-vZGtcrs9BdUlBEHm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vZGtcrs9BdUlBEHm .rough-node .label,#mermaid-svg-vZGtcrs9BdUlBEHm .node .label,#mermaid-svg-vZGtcrs9BdUlBEHm .image-shape .label,#mermaid-svg-vZGtcrs9BdUlBEHm .icon-shape .label{text-align:center;}#mermaid-svg-vZGtcrs9BdUlBEHm .node.clickable{cursor:pointer;}#mermaid-svg-vZGtcrs9BdUlBEHm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vZGtcrs9BdUlBEHm .arrowheadPath{fill:#333333;}#mermaid-svg-vZGtcrs9BdUlBEHm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vZGtcrs9BdUlBEHm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vZGtcrs9BdUlBEHm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vZGtcrs9BdUlBEHm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vZGtcrs9BdUlBEHm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vZGtcrs9BdUlBEHm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vZGtcrs9BdUlBEHm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vZGtcrs9BdUlBEHm .cluster text{fill:#333;}#mermaid-svg-vZGtcrs9BdUlBEHm .cluster span{color:#333;}#mermaid-svg-vZGtcrs9BdUlBEHm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vZGtcrs9BdUlBEHm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vZGtcrs9BdUlBEHm rect.text{fill:none;stroke-width:0;}#mermaid-svg-vZGtcrs9BdUlBEHm .icon-shape,#mermaid-svg-vZGtcrs9BdUlBEHm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vZGtcrs9BdUlBEHm .icon-shape p,#mermaid-svg-vZGtcrs9BdUlBEHm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vZGtcrs9BdUlBEHm .icon-shape .label rect,#mermaid-svg-vZGtcrs9BdUlBEHm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vZGtcrs9BdUlBEHm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vZGtcrs9BdUlBEHm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vZGtcrs9BdUlBEHm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 服务端-单体Tomcat
客户端
1.携带Cookie
2.提取sessionId
3.查询本地Session
4.返回用户对象
5.注入ThreadLocal
6.放行
7.返回响应
浏览器
Cookie: sessionId=abc123
请求入口
拦截器
Session内存存储
ThreadLocal上下文
业务Controller
流程说明:
- 用户登录后,服务端在本地内存创建Session,生成
sessionId=abc123 - 通过
Set-Cookie响应头将sessionId下发至浏览器 - 后续请求自动携带该Cookie,拦截器从中提取sessionId
- 拦截器在当前进程内存中查找对应的用户数据
- 查找成功后,将用户对象存入
ThreadLocal,供当前线程内所有组件共享 - 业务代码通过
UserContext.get()直接获取当前登录用户,无需重复查询
关键特征:状态与进程绑定,同一台服务器内流转高效,但无法跨节点共享。
1.2 负载均衡引发的状态无法共享
当业务并发量突破单机承载极限,架构必然向多节点集群演进。此时引入Nginx或云厂商负载均衡器进行流量分发,传统Session机制暴露出致命缺陷:每台服务器拥有独立的内存空间,Session数据默认仅存在于创建它的节点。
#mermaid-svg-0n6yNJi9oZ2azcAT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0n6yNJi9oZ2azcAT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0n6yNJi9oZ2azcAT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0n6yNJi9oZ2azcAT .error-icon{fill:#552222;}#mermaid-svg-0n6yNJi9oZ2azcAT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0n6yNJi9oZ2azcAT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0n6yNJi9oZ2azcAT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0n6yNJi9oZ2azcAT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0n6yNJi9oZ2azcAT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0n6yNJi9oZ2azcAT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0n6yNJi9oZ2azcAT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0n6yNJi9oZ2azcAT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0n6yNJi9oZ2azcAT .marker.cross{stroke:#333333;}#mermaid-svg-0n6yNJi9oZ2azcAT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0n6yNJi9oZ2azcAT p{margin:0;}#mermaid-svg-0n6yNJi9oZ2azcAT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-0n6yNJi9oZ2azcAT .cluster-label text{fill:#333;}#mermaid-svg-0n6yNJi9oZ2azcAT .cluster-label span{color:#333;}#mermaid-svg-0n6yNJi9oZ2azcAT .cluster-label span p{background-color:transparent;}#mermaid-svg-0n6yNJi9oZ2azcAT .label text,#mermaid-svg-0n6yNJi9oZ2azcAT span{fill:#333;color:#333;}#mermaid-svg-0n6yNJi9oZ2azcAT .node rect,#mermaid-svg-0n6yNJi9oZ2azcAT .node circle,#mermaid-svg-0n6yNJi9oZ2azcAT .node ellipse,#mermaid-svg-0n6yNJi9oZ2azcAT .node polygon,#mermaid-svg-0n6yNJi9oZ2azcAT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0n6yNJi9oZ2azcAT .rough-node .label text,#mermaid-svg-0n6yNJi9oZ2azcAT .node .label text,#mermaid-svg-0n6yNJi9oZ2azcAT .image-shape .label,#mermaid-svg-0n6yNJi9oZ2azcAT .icon-shape .label{text-anchor:middle;}#mermaid-svg-0n6yNJi9oZ2azcAT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0n6yNJi9oZ2azcAT .rough-node .label,#mermaid-svg-0n6yNJi9oZ2azcAT .node .label,#mermaid-svg-0n6yNJi9oZ2azcAT .image-shape .label,#mermaid-svg-0n6yNJi9oZ2azcAT .icon-shape .label{text-align:center;}#mermaid-svg-0n6yNJi9oZ2azcAT .node.clickable{cursor:pointer;}#mermaid-svg-0n6yNJi9oZ2azcAT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0n6yNJi9oZ2azcAT .arrowheadPath{fill:#333333;}#mermaid-svg-0n6yNJi9oZ2azcAT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0n6yNJi9oZ2azcAT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0n6yNJi9oZ2azcAT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0n6yNJi9oZ2azcAT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0n6yNJi9oZ2azcAT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0n6yNJi9oZ2azcAT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0n6yNJi9oZ2azcAT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0n6yNJi9oZ2azcAT .cluster text{fill:#333;}#mermaid-svg-0n6yNJi9oZ2azcAT .cluster span{color:#333;}#mermaid-svg-0n6yNJi9oZ2azcAT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-0n6yNJi9oZ2azcAT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0n6yNJi9oZ2azcAT rect.text{fill:none;stroke-width:0;}#mermaid-svg-0n6yNJi9oZ2azcAT .icon-shape,#mermaid-svg-0n6yNJi9oZ2azcAT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0n6yNJi9oZ2azcAT .icon-shape p,#mermaid-svg-0n6yNJi9oZ2azcAT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0n6yNJi9oZ2azcAT .icon-shape .label rect,#mermaid-svg-0n6yNJi9oZ2azcAT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0n6yNJi9oZ2azcAT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0n6yNJi9oZ2azcAT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0n6yNJi9oZ2azcAT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Cluster
LB
Client
请求1: 登录
Set-Cookie
请求2: 查询
未找到
401 未登录
浏览器
Cookie: sessionId=abc123
Nginx / SLB
Tomcat-A
Session: abc123=user1
Tomcat-B
Session: 空
问题本质:
- 用户首次请求被路由至Tomcat-A完成登录,Session数据仅保存在A节点内存
- 第二次请求被负载均衡策略分发至Tomcat-B ,B节点内存中不存在
sessionId=abc123 - 拦截器查询本地Session失败,判定用户未登录,强制返回401未授权
- 用户被迫重复登录,且集群节点越多,状态丢失概率越高
核心矛盾:本地内存状态 与 分布式流量分发 的冲突。
1.3 选择Redis作为共享会话存储
要彻底解决集群环境下的状态共享问题,替代方案必须同时满足三项硬性指标:跨节点数据共享 、纯内存极速读写 、键值映射结构。Redis作为分布式内存数据库,天然具备这三项特性。
#mermaid-svg-g3yQ34zabBUAkEQ7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-g3yQ34zabBUAkEQ7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-g3yQ34zabBUAkEQ7 .error-icon{fill:#552222;}#mermaid-svg-g3yQ34zabBUAkEQ7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-g3yQ34zabBUAkEQ7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-g3yQ34zabBUAkEQ7 .marker.cross{stroke:#333333;}#mermaid-svg-g3yQ34zabBUAkEQ7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-g3yQ34zabBUAkEQ7 p{margin:0;}#mermaid-svg-g3yQ34zabBUAkEQ7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-g3yQ34zabBUAkEQ7 .cluster-label text{fill:#333;}#mermaid-svg-g3yQ34zabBUAkEQ7 .cluster-label span{color:#333;}#mermaid-svg-g3yQ34zabBUAkEQ7 .cluster-label span p{background-color:transparent;}#mermaid-svg-g3yQ34zabBUAkEQ7 .label text,#mermaid-svg-g3yQ34zabBUAkEQ7 span{fill:#333;color:#333;}#mermaid-svg-g3yQ34zabBUAkEQ7 .node rect,#mermaid-svg-g3yQ34zabBUAkEQ7 .node circle,#mermaid-svg-g3yQ34zabBUAkEQ7 .node ellipse,#mermaid-svg-g3yQ34zabBUAkEQ7 .node polygon,#mermaid-svg-g3yQ34zabBUAkEQ7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-g3yQ34zabBUAkEQ7 .rough-node .label text,#mermaid-svg-g3yQ34zabBUAkEQ7 .node .label text,#mermaid-svg-g3yQ34zabBUAkEQ7 .image-shape .label,#mermaid-svg-g3yQ34zabBUAkEQ7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-g3yQ34zabBUAkEQ7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-g3yQ34zabBUAkEQ7 .rough-node .label,#mermaid-svg-g3yQ34zabBUAkEQ7 .node .label,#mermaid-svg-g3yQ34zabBUAkEQ7 .image-shape .label,#mermaid-svg-g3yQ34zabBUAkEQ7 .icon-shape .label{text-align:center;}#mermaid-svg-g3yQ34zabBUAkEQ7 .node.clickable{cursor:pointer;}#mermaid-svg-g3yQ34zabBUAkEQ7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-g3yQ34zabBUAkEQ7 .arrowheadPath{fill:#333333;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-g3yQ34zabBUAkEQ7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-g3yQ34zabBUAkEQ7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-g3yQ34zabBUAkEQ7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-g3yQ34zabBUAkEQ7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-g3yQ34zabBUAkEQ7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-g3yQ34zabBUAkEQ7 .cluster text{fill:#333;}#mermaid-svg-g3yQ34zabBUAkEQ7 .cluster span{color:#333;}#mermaid-svg-g3yQ34zabBUAkEQ7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-g3yQ34zabBUAkEQ7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-g3yQ34zabBUAkEQ7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-g3yQ34zabBUAkEQ7 .icon-shape,#mermaid-svg-g3yQ34zabBUAkEQ7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-g3yQ34zabBUAkEQ7 .icon-shape p,#mermaid-svg-g3yQ34zabBUAkEQ7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-g3yQ34zabBUAkEQ7 .icon-shape .label rect,#mermaid-svg-g3yQ34zabBUAkEQ7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-g3yQ34zabBUAkEQ7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-g3yQ34zabBUAkEQ7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-g3yQ34zabBUAkEQ7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Storage
AppCluster
LB
Client
携带Token
路由
查询Token
返回用户数据
注入请求状态
返回响应
路由
查询Token
返回相同数据
浏览器
Cookie: token=uuid-xyz
负载均衡 Nginx
Tomcat-A
Tomcat-B
Tomcat-C
Redis集群
业务逻辑处理
架构优势:
| 特性 | 传统Session方案 | Redis共享会话方案 |
|---|---|---|
| 数据存储位置 | 各节点本地内存 | 集中式Redis集群 |
| 跨节点访问 | 无法共享 | 任意节点均可读取 |
| 状态一致性 | 依赖会话粘滞(有单点风险) | 天然一致,无粘滞依赖 |
| 扩展性 | 水平扩展需解决状态同步 | 无状态节点,任意扩缩容 |
| 故障恢复 | 节点宕机会话丢失 | Redis持久化+主从保障 |
关键设计决策:
- 键名规范 :验证码用
phone:{手机号},会话用token:{uuid},业务语义清晰且防冲突 - 数据结构:验证码用String(简单键值),会话用Hash(支持字段级更新+内存压缩)
- 生命周期:所有Key绑定TTL,过期自动清理,避免内存泄漏
核心:将会话状态从"进程内存"迁移到"分布式缓存",应用节点彻底无状态化,负载均衡可任意路由,系统具备弹性伸缩与高可用能力。
二、 核心业务流转设计
系统采用双阶段验证机制,彻底摒弃传统密码体系,依赖动态验证码完成身份确权与分布式会话初始化。整个流程严格遵循校验前置、状态后置的设计原则。
2.1 验证码下发阶段
客户端提交手机号码后,服务端优先执行格式校验。校验通过后,在内存中生成六位随机数字验证码。此处不依赖外部短信网关,而是通过后端模拟机制将验证码输出至运行日志,仅用于本地调试与核心链路验证。
生成的验证码会立即写入Redis,建立临时绑定关系。存储时Key采用 phone:{手机号} 的规范格式。随后设置短暂的过期时间,确保验证码具备严格的时效性。写入完成后接口直接返回成功响应,流程闭环。
Redis 服务端 客户端 Redis 服务端 客户端 #mermaid-svg-XLd7pj4WuPc2rtJD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XLd7pj4WuPc2rtJD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XLd7pj4WuPc2rtJD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XLd7pj4WuPc2rtJD .error-icon{fill:#552222;}#mermaid-svg-XLd7pj4WuPc2rtJD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XLd7pj4WuPc2rtJD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XLd7pj4WuPc2rtJD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XLd7pj4WuPc2rtJD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XLd7pj4WuPc2rtJD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XLd7pj4WuPc2rtJD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XLd7pj4WuPc2rtJD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XLd7pj4WuPc2rtJD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XLd7pj4WuPc2rtJD .marker.cross{stroke:#333333;}#mermaid-svg-XLd7pj4WuPc2rtJD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XLd7pj4WuPc2rtJD p{margin:0;}#mermaid-svg-XLd7pj4WuPc2rtJD .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XLd7pj4WuPc2rtJD text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-XLd7pj4WuPc2rtJD .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XLd7pj4WuPc2rtJD .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-XLd7pj4WuPc2rtJD .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-XLd7pj4WuPc2rtJD .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-XLd7pj4WuPc2rtJD #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-XLd7pj4WuPc2rtJD .sequenceNumber{fill:white;}#mermaid-svg-XLd7pj4WuPc2rtJD #sequencenumber{fill:#333;}#mermaid-svg-XLd7pj4WuPc2rtJD #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-XLd7pj4WuPc2rtJD .messageText{fill:#333;stroke:none;}#mermaid-svg-XLd7pj4WuPc2rtJD .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XLd7pj4WuPc2rtJD .labelText,#mermaid-svg-XLd7pj4WuPc2rtJD .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-XLd7pj4WuPc2rtJD .loopText,#mermaid-svg-XLd7pj4WuPc2rtJD .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-XLd7pj4WuPc2rtJD .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-XLd7pj4WuPc2rtJD .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-XLd7pj4WuPc2rtJD .noteText,#mermaid-svg-XLd7pj4WuPc2rtJD .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-XLd7pj4WuPc2rtJD .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XLd7pj4WuPc2rtJD .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XLd7pj4WuPc2rtJD .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-XLd7pj4WuPc2rtJD .actorPopupMenu{position:absolute;}#mermaid-svg-XLd7pj4WuPc2rtJD .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-XLd7pj4WuPc2rtJD .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-XLd7pj4WuPc2rtJD .actor-man circle,#mermaid-svg-XLd7pj4WuPc2rtJD line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-XLd7pj4WuPc2rtJD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /send-code (phone) 正则校验手机号格式 生成6位随机码 code SETEX phone:138****1234 120 "834729" OK logging.info("验证码: 834729") {"message": "验证码已发送"}
关键代码片段 - 业务逻辑层
python
async def send_code(phone: str) -> None:
# 1. 生成6位随机验证码
code = random.randint(100000, 999999)
redis_client = await get_redis()
# 2. 原子操作:设置值 + 120秒过期(避免set与expire分离导致竞态)
await redis_client.setex(f"phone:{phone}", settings.code_ttl, str(code))
logging.info(f"验证码已下发: {phone} -> {code}")
2.2 登录与注册合一阶段
用户收到验证码后,提交手机号与验证码至登录接口。服务端以 phone:{手机号} 为Key向Redis发起查询,提取暂存验证码进行比对。若不一致,直接拒绝请求;若一致,服务端根据手机号查询数据库。
若数据库中已存在该用户记录,流程直接进入会话建立环节;若不存在,系统自动执行新用户创建逻辑。会话建立时,服务端调用UUID生成器创建高随机字符串作为Token。以该Token构建 token:{uuid} 作为Key,将用户核心信息写入Redis,并设置较长的会话存活时间。最终将Token返回客户端,由浏览器保存至Cookie中,完成分布式会话的初始化。
MySQL Redis 服务端 客户端 MySQL Redis 服务端 客户端 #mermaid-svg-NW168WBDE2eh21Ju{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NW168WBDE2eh21Ju .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NW168WBDE2eh21Ju .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NW168WBDE2eh21Ju .error-icon{fill:#552222;}#mermaid-svg-NW168WBDE2eh21Ju .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NW168WBDE2eh21Ju .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NW168WBDE2eh21Ju .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NW168WBDE2eh21Ju .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NW168WBDE2eh21Ju .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NW168WBDE2eh21Ju .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NW168WBDE2eh21Ju .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NW168WBDE2eh21Ju .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NW168WBDE2eh21Ju .marker.cross{stroke:#333333;}#mermaid-svg-NW168WBDE2eh21Ju svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NW168WBDE2eh21Ju p{margin:0;}#mermaid-svg-NW168WBDE2eh21Ju .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NW168WBDE2eh21Ju text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-NW168WBDE2eh21Ju .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-NW168WBDE2eh21Ju .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-NW168WBDE2eh21Ju .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-NW168WBDE2eh21Ju .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-NW168WBDE2eh21Ju #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-NW168WBDE2eh21Ju .sequenceNumber{fill:white;}#mermaid-svg-NW168WBDE2eh21Ju #sequencenumber{fill:#333;}#mermaid-svg-NW168WBDE2eh21Ju #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-NW168WBDE2eh21Ju .messageText{fill:#333;stroke:none;}#mermaid-svg-NW168WBDE2eh21Ju .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NW168WBDE2eh21Ju .labelText,#mermaid-svg-NW168WBDE2eh21Ju .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-NW168WBDE2eh21Ju .loopText,#mermaid-svg-NW168WBDE2eh21Ju .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-NW168WBDE2eh21Ju .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-NW168WBDE2eh21Ju .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-NW168WBDE2eh21Ju .noteText,#mermaid-svg-NW168WBDE2eh21Ju .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-NW168WBDE2eh21Ju .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NW168WBDE2eh21Ju .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NW168WBDE2eh21Ju .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NW168WBDE2eh21Ju .actorPopupMenu{position:absolute;}#mermaid-svg-NW168WBDE2eh21Ju .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-NW168WBDE2eh21Ju .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NW168WBDE2eh21Ju .actor-man circle,#mermaid-svg-NW168WBDE2eh21Ju line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-NW168WBDE2eh21Ju :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 新用户 老用户 alt 验证码正确 验证码错误/过期 POST /login (手机号, 验证码) GET 缓存中的验证码 返回正确验证码 DELETE 验证码(防重放) 查询用户是否存在 插入用户记录 返回新记录 返回已有记录 生成UUID Token HSET Token用户信息 EXPIRE Token 1800秒 Set-Cookie: Token HttpOnly 401 未授权
关键代码片段 - 业务逻辑层
python
async def login(db: AsyncSession, phone: str, code: int) -> tuple[str, dict]:
redis_client = await get_redis()
# 1. 校验验证码
stored_code = await redis_client.get(f"phone:{phone}")
if stored_code is None or int(stored_code) != code:
raise ValueError("验证码已过期或错误")
# 2. 一次性销毁,防重放攻击
await redis_client.delete(f"phone:{phone}")
# 3. 查询或自动注册
user = await get_user_by_phone(db, phone)
if user is None:
user = await create_user(db, phone, f"用户{phone[-4:]}")
# 4. 生成Token并写入Redis Hash结构
token = str(uuid.uuid4())
await redis_client.hset(f"token:{token}", mapping={
"id": str(user.id), "name": user.name, "phone": user.phone
})
await redis_client.expire(f"token:{token}", settings.session_ttl)
return token, {"id": user.id, "name": user.name, "phone": user.phone}
三、 共享会话架构与全局鉴权实现
在集群架构中,鉴权逻辑必须脱离单一节点限制。系统默认采用全局统一的认证依赖层,所有受保护接口无需单独配置拦截规则,请求到达路由前均会经过统一的鉴权处理。
3.1 全局认证流水线
请求携带Cookie到达服务端时,鉴权层优先从中提取Token。以 token:{uuid} 为Key向Redis发起查询。若Redis返回空值,说明会话已失效或从未登录,鉴权层直接阻断请求。若查询到用户数据,鉴权层将该数据绑定至当前请求的上下文隔离空间,确保后续业务逻辑可直接获取当前登录用户对象,无需重复传参或查询。
绑定完成后,鉴权层会立即调用Redis的续期命令,重置该Token的存活时间,实现会话自动保活。随后放行请求至业务控制器。下游业务逻辑完全解耦,仅专注于领域操作,认证状态由全局流水线统一托管。
#mermaid-svg-RlgUqm19XAfD7w1Q{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RlgUqm19XAfD7w1Q .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RlgUqm19XAfD7w1Q .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RlgUqm19XAfD7w1Q .error-icon{fill:#552222;}#mermaid-svg-RlgUqm19XAfD7w1Q .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RlgUqm19XAfD7w1Q .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RlgUqm19XAfD7w1Q .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RlgUqm19XAfD7w1Q .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RlgUqm19XAfD7w1Q .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RlgUqm19XAfD7w1Q .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RlgUqm19XAfD7w1Q .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RlgUqm19XAfD7w1Q .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RlgUqm19XAfD7w1Q .marker.cross{stroke:#333333;}#mermaid-svg-RlgUqm19XAfD7w1Q svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RlgUqm19XAfD7w1Q p{margin:0;}#mermaid-svg-RlgUqm19XAfD7w1Q .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RlgUqm19XAfD7w1Q .cluster-label text{fill:#333;}#mermaid-svg-RlgUqm19XAfD7w1Q .cluster-label span{color:#333;}#mermaid-svg-RlgUqm19XAfD7w1Q .cluster-label span p{background-color:transparent;}#mermaid-svg-RlgUqm19XAfD7w1Q .label text,#mermaid-svg-RlgUqm19XAfD7w1Q span{fill:#333;color:#333;}#mermaid-svg-RlgUqm19XAfD7w1Q .node rect,#mermaid-svg-RlgUqm19XAfD7w1Q .node circle,#mermaid-svg-RlgUqm19XAfD7w1Q .node ellipse,#mermaid-svg-RlgUqm19XAfD7w1Q .node polygon,#mermaid-svg-RlgUqm19XAfD7w1Q .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RlgUqm19XAfD7w1Q .rough-node .label text,#mermaid-svg-RlgUqm19XAfD7w1Q .node .label text,#mermaid-svg-RlgUqm19XAfD7w1Q .image-shape .label,#mermaid-svg-RlgUqm19XAfD7w1Q .icon-shape .label{text-anchor:middle;}#mermaid-svg-RlgUqm19XAfD7w1Q .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RlgUqm19XAfD7w1Q .rough-node .label,#mermaid-svg-RlgUqm19XAfD7w1Q .node .label,#mermaid-svg-RlgUqm19XAfD7w1Q .image-shape .label,#mermaid-svg-RlgUqm19XAfD7w1Q .icon-shape .label{text-align:center;}#mermaid-svg-RlgUqm19XAfD7w1Q .node.clickable{cursor:pointer;}#mermaid-svg-RlgUqm19XAfD7w1Q .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RlgUqm19XAfD7w1Q .arrowheadPath{fill:#333333;}#mermaid-svg-RlgUqm19XAfD7w1Q .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RlgUqm19XAfD7w1Q .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RlgUqm19XAfD7w1Q .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RlgUqm19XAfD7w1Q .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RlgUqm19XAfD7w1Q .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RlgUqm19XAfD7w1Q .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RlgUqm19XAfD7w1Q .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RlgUqm19XAfD7w1Q .cluster text{fill:#333;}#mermaid-svg-RlgUqm19XAfD7w1Q .cluster span{color:#333;}#mermaid-svg-RlgUqm19XAfD7w1Q div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RlgUqm19XAfD7w1Q .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RlgUqm19XAfD7w1Q rect.text{fill:none;stroke-width:0;}#mermaid-svg-RlgUqm19XAfD7w1Q .icon-shape,#mermaid-svg-RlgUqm19XAfD7w1Q .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RlgUqm19XAfD7w1Q .icon-shape p,#mermaid-svg-RlgUqm19XAfD7w1Q .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RlgUqm19XAfD7w1Q .icon-shape .label rect,#mermaid-svg-RlgUqm19XAfD7w1Q .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RlgUqm19XAfD7w1Q .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RlgUqm19XAfD7w1Q .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RlgUqm19XAfD7w1Q :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Token=None
Token=uuid
空
命中
请求携带Cookie到达
提取Token
拦截: 401 未登录
Redis: HGETALL token:uuid
会话是否存在
拦截: 401 会话过期
注入 request.state.user
Redis: EXPIRE token:uuid 1800 滑动续期
放行至业务路由
业务层直接使用 request.state.user
关键代码片段 - 全局鉴权依赖
python
async def get_current_user(request: Request, token: str = Cookie(None)):
if token is None:
raise HTTPException(status_code=401, detail="未登录")
redis_client = await get_redis()
user_data = await redis_client.hgetall(f"token:{token}")
if not user_data:
raise HTTPException(status_code=401, detail="会话已过期")
# 注入用户数据到请求上下文,替代传统 ThreadLocal
request.state.user = user_data
# 滑动窗口续期:每次有效请求重置TTL
await redis_client.expire(f"token:{token}", settings.session_ttl)
关键代码片段 - 业务路由调用
python
@router.get("/me")
async def me(request: Request, _ = Depends(get_current_user)):
# 零鉴权样板代码,直接读取已注入的上下文
return request.state.user
四、 Redis键值映射与数据结构选型
Redis平铺存储所有数据,缺乏数据库与表的概念,因此Key的命名必须具备强业务语义与防冲突能力。临时数据与长期会话必须严格隔离,避免语义混淆与内存泄漏。
4.1 键名设计规范
临时验证码采用 phone:{手机号} 格式。前缀明确标识业务用途,后缀绑定具体资源。该设计避免与用户会话Key产生碰撞,同时便于运维人员通过模式匹配快速定位或批量清理过期数据。
用户会话采用 token:{uuid} 格式。Token具备不可预测性,有效防止恶意遍历或会话劫持。前缀 token: 用于在监控面板中快速识别会话类数据,与业务缓存、配置缓存严格区分。
#mermaid-svg-5TbyEiXAHMMIfzWN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5TbyEiXAHMMIfzWN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5TbyEiXAHMMIfzWN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5TbyEiXAHMMIfzWN .error-icon{fill:#552222;}#mermaid-svg-5TbyEiXAHMMIfzWN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5TbyEiXAHMMIfzWN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5TbyEiXAHMMIfzWN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5TbyEiXAHMMIfzWN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5TbyEiXAHMMIfzWN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5TbyEiXAHMMIfzWN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5TbyEiXAHMMIfzWN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5TbyEiXAHMMIfzWN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5TbyEiXAHMMIfzWN .marker.cross{stroke:#333333;}#mermaid-svg-5TbyEiXAHMMIfzWN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5TbyEiXAHMMIfzWN p{margin:0;}#mermaid-svg-5TbyEiXAHMMIfzWN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5TbyEiXAHMMIfzWN .cluster-label text{fill:#333;}#mermaid-svg-5TbyEiXAHMMIfzWN .cluster-label span{color:#333;}#mermaid-svg-5TbyEiXAHMMIfzWN .cluster-label span p{background-color:transparent;}#mermaid-svg-5TbyEiXAHMMIfzWN .label text,#mermaid-svg-5TbyEiXAHMMIfzWN span{fill:#333;color:#333;}#mermaid-svg-5TbyEiXAHMMIfzWN .node rect,#mermaid-svg-5TbyEiXAHMMIfzWN .node circle,#mermaid-svg-5TbyEiXAHMMIfzWN .node ellipse,#mermaid-svg-5TbyEiXAHMMIfzWN .node polygon,#mermaid-svg-5TbyEiXAHMMIfzWN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5TbyEiXAHMMIfzWN .rough-node .label text,#mermaid-svg-5TbyEiXAHMMIfzWN .node .label text,#mermaid-svg-5TbyEiXAHMMIfzWN .image-shape .label,#mermaid-svg-5TbyEiXAHMMIfzWN .icon-shape .label{text-anchor:middle;}#mermaid-svg-5TbyEiXAHMMIfzWN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5TbyEiXAHMMIfzWN .rough-node .label,#mermaid-svg-5TbyEiXAHMMIfzWN .node .label,#mermaid-svg-5TbyEiXAHMMIfzWN .image-shape .label,#mermaid-svg-5TbyEiXAHMMIfzWN .icon-shape .label{text-align:center;}#mermaid-svg-5TbyEiXAHMMIfzWN .node.clickable{cursor:pointer;}#mermaid-svg-5TbyEiXAHMMIfzWN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5TbyEiXAHMMIfzWN .arrowheadPath{fill:#333333;}#mermaid-svg-5TbyEiXAHMMIfzWN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5TbyEiXAHMMIfzWN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5TbyEiXAHMMIfzWN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5TbyEiXAHMMIfzWN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5TbyEiXAHMMIfzWN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5TbyEiXAHMMIfzWN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5TbyEiXAHMMIfzWN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5TbyEiXAHMMIfzWN .cluster text{fill:#333;}#mermaid-svg-5TbyEiXAHMMIfzWN .cluster span{color:#333;}#mermaid-svg-5TbyEiXAHMMIfzWN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5TbyEiXAHMMIfzWN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5TbyEiXAHMMIfzWN rect.text{fill:none;stroke-width:0;}#mermaid-svg-5TbyEiXAHMMIfzWN .icon-shape,#mermaid-svg-5TbyEiXAHMMIfzWN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5TbyEiXAHMMIfzWN .icon-shape p,#mermaid-svg-5TbyEiXAHMMIfzWN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5TbyEiXAHMMIfzWN .icon-shape .label rect,#mermaid-svg-5TbyEiXAHMMIfzWN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5TbyEiXAHMMIfzWN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5TbyEiXAHMMIfzWN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5TbyEiXAHMMIfzWN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Redis命名空间隔离
短期/业务相关
长期/高随机
phone:138****1234
Type: String
TTL: 120s
token:550e8400-...
Type: Hash
TTL: 1800s
product:1001
Type: Hash
TTL: 3600s
关键代码片段 - Key命名与生命周期
python
# 验证码Key:业务相关,短期有效,校验后立即删除
await redis_client.setex(f"phone:{phone}", settings.code_ttl, str(code))
# 会话Key:高随机性,不可预测,滑动续期
token = str(uuid.uuid4())
await redis_client.hset(f"token:{token}", mapping=user_dict)
await redis_client.expire(f"token:{token}", settings.session_ttl)
4.2 用户信息存储结构对比
保存用户会话数据时,存在String与Hash两种结构选择,系统采用Hash结构。
若使用String结构,需将用户对象序列化为JSON字符串整体存储。读取时一次性反序列化即可,但当业务仅需更新用户昵称或头像时,必须拉取完整JSON、修改字段、重新序列化后写回。该过程产生不必要的网络传输与CPU序列化开销。
Hash结构将用户对象拆解为独立的字段存储。Redis底层对Hash进行压缩编码优化,在存储对象属性时内存占用显著低于String结构。更重要的是,Hash支持针对单个字段执行读写操作。更新字段时仅需操作对应Key,无需触碰其他数据。对于需要频繁局部更新的会话场景,Hash结构提供更细粒度的操作能力与更优的资源表现。
#mermaid-svg-oiCO2Eodf8m1cUU3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oiCO2Eodf8m1cUU3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oiCO2Eodf8m1cUU3 .error-icon{fill:#552222;}#mermaid-svg-oiCO2Eodf8m1cUU3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oiCO2Eodf8m1cUU3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oiCO2Eodf8m1cUU3 .marker.cross{stroke:#333333;}#mermaid-svg-oiCO2Eodf8m1cUU3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oiCO2Eodf8m1cUU3 p{margin:0;}#mermaid-svg-oiCO2Eodf8m1cUU3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oiCO2Eodf8m1cUU3 .cluster-label text{fill:#333;}#mermaid-svg-oiCO2Eodf8m1cUU3 .cluster-label span{color:#333;}#mermaid-svg-oiCO2Eodf8m1cUU3 .cluster-label span p{background-color:transparent;}#mermaid-svg-oiCO2Eodf8m1cUU3 .label text,#mermaid-svg-oiCO2Eodf8m1cUU3 span{fill:#333;color:#333;}#mermaid-svg-oiCO2Eodf8m1cUU3 .node rect,#mermaid-svg-oiCO2Eodf8m1cUU3 .node circle,#mermaid-svg-oiCO2Eodf8m1cUU3 .node ellipse,#mermaid-svg-oiCO2Eodf8m1cUU3 .node polygon,#mermaid-svg-oiCO2Eodf8m1cUU3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oiCO2Eodf8m1cUU3 .rough-node .label text,#mermaid-svg-oiCO2Eodf8m1cUU3 .node .label text,#mermaid-svg-oiCO2Eodf8m1cUU3 .image-shape .label,#mermaid-svg-oiCO2Eodf8m1cUU3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-oiCO2Eodf8m1cUU3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oiCO2Eodf8m1cUU3 .rough-node .label,#mermaid-svg-oiCO2Eodf8m1cUU3 .node .label,#mermaid-svg-oiCO2Eodf8m1cUU3 .image-shape .label,#mermaid-svg-oiCO2Eodf8m1cUU3 .icon-shape .label{text-align:center;}#mermaid-svg-oiCO2Eodf8m1cUU3 .node.clickable{cursor:pointer;}#mermaid-svg-oiCO2Eodf8m1cUU3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oiCO2Eodf8m1cUU3 .arrowheadPath{fill:#333333;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oiCO2Eodf8m1cUU3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oiCO2Eodf8m1cUU3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oiCO2Eodf8m1cUU3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oiCO2Eodf8m1cUU3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oiCO2Eodf8m1cUU3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oiCO2Eodf8m1cUU3 .cluster text{fill:#333;}#mermaid-svg-oiCO2Eodf8m1cUU3 .cluster span{color:#333;}#mermaid-svg-oiCO2Eodf8m1cUU3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oiCO2Eodf8m1cUU3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oiCO2Eodf8m1cUU3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-oiCO2Eodf8m1cUU3 .icon-shape,#mermaid-svg-oiCO2Eodf8m1cUU3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oiCO2Eodf8m1cUU3 .icon-shape p,#mermaid-svg-oiCO2Eodf8m1cUU3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oiCO2Eodf8m1cUU3 .icon-shape .label rect,#mermaid-svg-oiCO2Eodf8m1cUU3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oiCO2Eodf8m1cUU3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oiCO2Eodf8m1cUU3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oiCO2Eodf8m1cUU3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HashMap
Hash结构 存储独立Field
更新字段: HSET 直接覆盖
StringJSON
String结构 存储完整JSON
更新字段: GET 解析 修改 SET
关键代码片段 - String vs Hash 操作对比
python
# String结构:需序列化/反序列化,局部更新繁琐
await redis_client.set(f"token:{t}", json.dumps({"id": 1, "name": "A"}))
data = json.loads(await redis_client.get(f"token:{t}"))
data["name"] = "B"
await redis_client.set(f"token:{t}", json.dumps(data)) # 整体覆盖
# Hash结构:字段级操作,无需序列化,内存更优
await redis_client.hset(f"token:{t}", "id", "1")
await redis_client.hset(f"token:{t}", "name", "A")
user_data = await redis_client.hgetall(f"token:{t}") # 直接返回字典
await redis_client.hset(f"token:{t}", "name", "B") # 仅更新该字段
五、FastAPI项目实现细节(内容已托管至Gitee)
项目采用全链路异步架构支撑高并发场景,以Redis为分布式状态中枢,FastAPI依赖注入为鉴权骨架,MySQL为持久化底座。整个认证模块严格遵循"校验前置、状态后置、无状态化交互"的设计原则,将传统单体Session的生命周期管理彻底迁移至内存缓存层。以下按核心业务流转顺序,逐层交付可直接投入生产的完整代码实现。
5.1 核心业务流程图
#mermaid-svg-1copxpCJp5KImdS2{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1copxpCJp5KImdS2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1copxpCJp5KImdS2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1copxpCJp5KImdS2 .error-icon{fill:#552222;}#mermaid-svg-1copxpCJp5KImdS2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1copxpCJp5KImdS2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1copxpCJp5KImdS2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1copxpCJp5KImdS2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1copxpCJp5KImdS2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1copxpCJp5KImdS2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1copxpCJp5KImdS2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1copxpCJp5KImdS2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1copxpCJp5KImdS2 .marker.cross{stroke:#333333;}#mermaid-svg-1copxpCJp5KImdS2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1copxpCJp5KImdS2 p{margin:0;}#mermaid-svg-1copxpCJp5KImdS2 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1copxpCJp5KImdS2 .cluster-label text{fill:#333;}#mermaid-svg-1copxpCJp5KImdS2 .cluster-label span{color:#333;}#mermaid-svg-1copxpCJp5KImdS2 .cluster-label span p{background-color:transparent;}#mermaid-svg-1copxpCJp5KImdS2 .label text,#mermaid-svg-1copxpCJp5KImdS2 span{fill:#333;color:#333;}#mermaid-svg-1copxpCJp5KImdS2 .node rect,#mermaid-svg-1copxpCJp5KImdS2 .node circle,#mermaid-svg-1copxpCJp5KImdS2 .node ellipse,#mermaid-svg-1copxpCJp5KImdS2 .node polygon,#mermaid-svg-1copxpCJp5KImdS2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1copxpCJp5KImdS2 .rough-node .label text,#mermaid-svg-1copxpCJp5KImdS2 .node .label text,#mermaid-svg-1copxpCJp5KImdS2 .image-shape .label,#mermaid-svg-1copxpCJp5KImdS2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-1copxpCJp5KImdS2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1copxpCJp5KImdS2 .rough-node .label,#mermaid-svg-1copxpCJp5KImdS2 .node .label,#mermaid-svg-1copxpCJp5KImdS2 .image-shape .label,#mermaid-svg-1copxpCJp5KImdS2 .icon-shape .label{text-align:center;}#mermaid-svg-1copxpCJp5KImdS2 .node.clickable{cursor:pointer;}#mermaid-svg-1copxpCJp5KImdS2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1copxpCJp5KImdS2 .arrowheadPath{fill:#333333;}#mermaid-svg-1copxpCJp5KImdS2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1copxpCJp5KImdS2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1copxpCJp5KImdS2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1copxpCJp5KImdS2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1copxpCJp5KImdS2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1copxpCJp5KImdS2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1copxpCJp5KImdS2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1copxpCJp5KImdS2 .cluster text{fill:#333;}#mermaid-svg-1copxpCJp5KImdS2 .cluster span{color:#333;}#mermaid-svg-1copxpCJp5KImdS2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1copxpCJp5KImdS2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1copxpCJp5KImdS2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-1copxpCJp5KImdS2 .icon-shape,#mermaid-svg-1copxpCJp5KImdS2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1copxpCJp5KImdS2 .icon-shape p,#mermaid-svg-1copxpCJp5KImdS2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1copxpCJp5KImdS2 .icon-shape .label rect,#mermaid-svg-1copxpCJp5KImdS2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1copxpCJp5KImdS2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1copxpCJp5KImdS2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1copxpCJp5KImdS2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Flow2
Flow1
失败
成功
不匹配
匹配
不存在
已存在
用户收到验证码后触发
客户端提交手机号
参数格式校验
返回422错误
生成6位随机码
Redis SETEX 验证码 120s
模拟短信下发
返回下发成功
提交手机号+验证码
Redis 获取验证码
验证码是否匹配
返回401错误
Redis DELETE 验证码
MySQL 查询用户
自动创建新用户
复用原记录
生成UUID Token
Redis HSET 写入用户数据
Redis EXPIRE 设置过期
Set-Cookie 返回Token
返回登录成功
5.2 流程一:验证码下发实现
5.2.1 请求参数校验模型 module_auth/schema.py
python
from pydantic import BaseModel, Field
class SendCodeRequest(BaseModel):
"""发送验证码请求模型
用正则 ^1[3-9]\\d{9}$ 验证手机号格式:
- 必须以1开头
- 第二位3-9
- 总共11位数字
"""
phone: str = Field(pattern=r"^1[3-9]\d{9}$")
class SendCodeResponse(BaseModel):
message: str
class LoginRequest(BaseModel):
phone: str
code: int
class LoginResponse(BaseModel):
token: str
id: int
name: str
phone: str
5.2.2 业务逻辑层 module_auth/service.py
python
import logging
import random
from common.config import settings
from common.redis import get_redis
async def send_code(phone: str) -> None:
"""发送验证码(模拟)
核心流程:
1. 生成 6 位随机数字验证码
2. 以 phone:{手机号} 为 key 存入 Redis,设置过期时间(120秒)
3. 打印日志模拟发送(生产环境对接短信网关)
"""
code = random.randint(100000, 999999)
redis_client = await get_redis()
# setex = set + expire,原子操作,避免过期时间与设置分离
await redis_client.setex(f"phone:{phone}", settings.code_ttl, str(code))
logging.info(f"验证码已下发: {phone} -> {code}")
5.2.3 路由控制器 module_auth/controller.py
python
from fastapi import APIRouter
from module_auth.schema import SendCodeRequest, SendCodeResponse
from module_auth.service import send_code
# 认证模块路由,前缀统一为 /api/auth
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/send-code", response_model=SendCodeResponse)
async def api_send_code(req: SendCodeRequest):
"""发送验证码接口
入参校验由 Pydantic 的 SendCodeRequest 自动处理(手机号格式正则)
"""
await send_code(req.phone)
return SendCodeResponse(message="验证码已发送")
5.3 流程二:登录与注册合一实现
5.3.1 用户数据模型 module_user/model.py
python
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from common.database import Base
class User(Base):
"""用户数据模型
存储核心用户信息,支持登录与注册合一的场景
"""
__tablename__ = "users"
# 用户 ID:主键,自增
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 手机号:唯一标识,加索引加速登录查询,固定 11 位
phone: Mapped[str] = mapped_column(String(11), unique=True, nullable=False, index=True)
# 用户名:默认取手机号后4位,允许后续修改
name: Mapped[str] = mapped_column(String(50), nullable=False)
5.3.2 用户CRUD操作 module_user/crud.py
python
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from module_user.model import User
async def get_user_by_phone(db: AsyncSession, phone: str) -> User | None:
"""通过手机号查询用户
登录时使用,判断用户是否已存在
scalar_one_or_none:有且仅有一条记录时返回,否则返回 None
"""
result = await db.execute(select(User).where(User.phone == phone))
return result.scalar_one_or_none()
async def create_user(db: AsyncSession, phone: str, name: str) -> User:
"""创建新用户
登录时如果用户不存在,自动执行注册
db.add:将对象加入会话
db.commit:提交事务到数据库
db.refresh:刷新对象,获取数据库自动生成的 ID
"""
user = User(phone=phone, name=name)
db.add(user)
await db.commit()
await db.refresh(user)
return user
5.3.3 业务逻辑层 module_auth/service.py
python
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from common.config import settings
from common.redis import get_redis
from module_user.crud import get_user_by_phone, create_user
async def login(db: AsyncSession, phone: str, code: int) -> tuple[str, dict]:
"""登录/注册合一核心逻辑
关键设计:
1. 先校验验证码,防止无效请求查库
2. 校验成功后立即删除验证码(一次性使用,防重放)
3. 自动判断用户是否存在:
- 存在:直接登录
- 不存在:自动注册(默认用户名取手机号后4位)
4. 生成 UUID Token,用 Hash 结构存用户信息到 Redis,支持局部字段更新
5. 返回 token 供前端存 Cookie
"""
redis_client = await get_redis()
stored_code = await redis_client.get(f"phone:{phone}")
if stored_code is None:
raise ValueError("验证码已过期或未发送")
if int(stored_code) != code:
raise ValueError("验证码错误")
# 验证码一次性使用,立即删除
await redis_client.delete(f"phone:{phone}")
# 查询用户,不存在则自动注册
user = await get_user_by_phone(db, phone)
if user is None:
user = await create_user(db, phone, f"用户{phone[-4:]}")
# 生成高不可预测性的 UUID Token
token = str(uuid.uuid4())
# 使用 Hash 结构存用户信息:
# - 好处:支持局部更新(如只修改 name 不用改整个对象)
# - key 格式:token:{token}
await redis_client.hset(f"token:{token}", "id", str(user.id))
await redis_client.hset(f"token:{token}", "name", user.name)
await redis_client.hset(f"token:{token}", "phone", user.phone)
await redis_client.expire(f"token:{token}", settings.session_ttl)
return token, {"id": user.id, "name": user.name, "phone": user.phone}
5.3.4 路由控制器 module_auth/controller.py
python
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.database import get_db
from module_auth.schema import LoginRequest, LoginResponse
from module_auth.service import login
# 认证模块路由,前缀统一为 /api/auth
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
async def api_login(req: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
"""登录/注册合一接口
核心安全设计:
1. token 设置为 httponly cookie,防止 XSS 攻击读取
2. 登录成功后设置 cookie,后续请求自动携带
"""
try:
token, user_info = await login(db, req.phone, req.code)
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
# httponly=True:前端 JS 无法读取,防止 XSS 窃取
# max_age:Cookie 有效期,与会话 TTL 一致
response.set_cookie(key="token", value=token, httponly=True, max_age=1800)
return LoginResponse(token=token, id=user_info["id"], name=user_info["name"], phone=user_info["phone"])
5.4 流程三:全局鉴权与会话续期实现
5.4.1 全局依赖注入 common/deps.py
python
from fastapi import Cookie, HTTPException, Request
from common.config import settings
from common.redis import get_redis
async def get_current_user(request: Request, token: str = Cookie(None)):
"""获取当前登录用户的全局鉴权依赖
核心逻辑:
1. 从 Cookie 中提取 token
2. 用 token 作为 key 从 Redis 哈希中读取用户信息
3. 如果读取不到,说明未登录或会话过期
4. 读取成功后,自动续期会话 TTL(每次操作刷新30分钟)
5. 将用户数据注入 request.state,方便下游接口直接使用
"""
if token is None:
raise HTTPException(status_code=401, detail="未登录")
redis_client = await get_redis()
user_data = await redis_client.hgetall(f"token:{token}")
if user_data == {}:
raise HTTPException(status_code=401, detail="会话已过期")
request.state.user = user_data
await redis_client.expire(f"token:{token}", settings.session_ttl)
# 无返回值,用户数据已注入 request.state
5.4.2 业务路由使用示例 module_user/controller.py
python
from fastapi import APIRouter, Depends, Request
from common.deps import get_current_user
router = APIRouter(prefix="/api/user", tags=["user"])
@router.get("/me")
async def me(request: Request, _ = Depends(get_current_user)):
"""获取当前登录用户信息接口
通过 get_current_user 依赖函数自动处理鉴权和会话续期
用户数据已注入到 request.state.user
"""
return request.state.user
5.5 项目配置与基础设施附录
5.5.1 配置中心 common/config.py
python
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""项目配置类
使用 pydantic-settings 自动从 .env 文件和环境变量读取配置
字段命名规则:小写下划线 对应 .env 中的大写下划线
"""
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
}
# 数据库配置:使用 aiomysql 异步驱动
database_url: str = "mysql+aiomysql://root:password@localhost:3306/phone_login"
# Redis 连接配置
redis_host: str = "localhost"
redis_port: int = 6379
redis_db: int = 0
redis_password: str = ""
# FastAPI 监听地址和端口
app_host: str = "0.0.0.0"
app_port: int = 8000
# 验证码过期时间(秒),通常 2 分钟安全且体验平衡
code_ttl: int = 120
# 会话过期时间(秒),30 分钟无操作自动过期
session_ttl: int = 1800
# 全局单例配置对象,其他模块直接导入使用
settings = Settings()
5.5.2 Redis连接管理 common/redis.py
python
import redis.asyncio as aioredis
from common.config import settings
# 全局 Redis 客户端实例,初始为 None,启动时初始化
redis_client: aioredis.Redis | None = None
async def init_redis():
"""初始化 Redis 异步客户端
在 FastAPI lifespan 的 startup 阶段调用
decode_responses=True:自动将 bytes 解码为 str,避免手动处理
"""
global redis_client
redis_client = aioredis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=settings.redis_db,
password=settings.redis_password or None,
decode_responses=True,
)
async def close_redis():
"""关闭 Redis 连接
在 FastAPI lifespan 的 shutdown 阶段调用,避免连接泄漏
"""
global redis_client
if redis_client:
await redis_client.aclose()
redis_client = None
async def get_redis() -> aioredis.Redis:
"""获取 Redis 客户端实例
其他模块通过此函数获取已初始化的客户端
"""
return redis_client
5.5.3 数据库会话管理 common/database.py
python
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from common.config import settings
# 创建异步 SQLAlchemy 引擎
# echo=True:开发时打印 SQL 方便调试,生产环境建议关闭
# pool_size:连接池大小,10个长期保持
# max_overflow:超出 pool_size 时可额外创建的连接数,共可支持30个并发
engine = create_async_engine(
settings.database_url,
echo=True,
pool_size=10,
max_overflow=20
)
# 创建异步会话工厂,通过 async_session() 获取会话
async_session = async_sessionmaker(bind=engine, class_=AsyncSession)
# SQLAlchemy ORM 基类,所有模型都继承自它
class Base(DeclarativeBase):
"""所有模型类的基类,包含公共字段(创建时间、更新时间)"""
create_time: Mapped[datetime] = mapped_column(
DateTime,
insert_default=func.now(),
default=datetime.now,
comment="创建时间"
)
update_time: Mapped[datetime] = mapped_column(
DateTime,
insert_default=func.now(),
onupdate=func.now(),
default=datetime.now,
comment="修改时间"
)
async def get_db():
"""获取异步数据库会话的依赖函数
使用 async with 自动管理事务和会话生命周期:
- 成功时自动提交
- 失败时自动回滚
- 结束时自动关闭会话
"""
async with async_session() as db:
yield db
5.5.4 应用入口与生命周期 main.py
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
from common.database import engine, Base
from common.redis import init_redis, close_redis
from module_auth.controller import router as auth_router
from module_user.controller import router as user_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI 生命周期管理
启动时(yield 之前):
1. 自动创建数据库表(如果不存在)
2. 初始化 Redis 连接
关闭时(yield 之后):
1. 关闭 Redis 连接,释放资源
"""
# 启动阶段:自动创建数据库表
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# 启动 Redis
await init_redis()
yield
# 关闭阶段:清理资源
await close_redis()
# 创建 FastAPI 应用
app = FastAPI(lifespan=lifespan)
# 注册各模块路由
app.include_router(auth_router)
app.include_router(user_router)
@app.get("/")
async def root():
"""健康检查/首页接口"""
return {"message": "Redis Phone Login Service"}
5.5.5 项目依赖 requirements.txt
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
redis>=5.0.0
sqlalchemy>=2.0.0
aiomysql>=0.2.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-multipart>=0.0.6
5.6 代码使用说明
5.6.1 项目启动
bash
# 1. 安装依赖
pip install -r requirements.txt
# 2. 创建 .env 配置文件(可选,覆盖默认配置)
echo "DATABASE_URL=mysql+aiomysql://user:pass@host:3306/db" > .env
echo "REDIS_HOST=127.0.0.1" >> .env
# 3. 启动服务
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
5.6.2 接口调用示例
发送验证码
bash
curl -X POST http://localhost:8000/api/auth/send-code \
-H "Content-Type: application/json" \
-d '{"phone": "13838411438"}'
登录/注册
bash
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"phone": "13838411438", "code": 834729}' \
-c cookies.txt
获取当前用户(携带Cookie)
bash
curl -X GET http://localhost:8000/api/user/me \
-b cookies.txt
5.6.3 Redis数据结构验证
bash
# 查看验证码(120秒内有效)
127.0.0.1:6379> GET phone:13838411438
"834729"
# 查看会话数据
127.0.0.1:6379> HGETALL token:550e8400-e29b-41d4-a716-446655440000
1) "id"
2) "1"
3) "name"
4) "用户1438"
5) "phone"
6) "13838411438"
# 查看剩余有效期
127.0.0.1:6379> TTL token:550e8400-e29b-41d4-a716-446655440000
(integer) 1785