
做后端维护的朋友应该都有同感HTTP协议是个不折不扣的“健忘症患者”它不记得你是谁、来过几次、上一步做了什么。可业务偏偏依赖这份记忆——用户登录之后不能一刷新就变成游客购物车里的东西也不能说没就没。会话管理Session就是化解这个矛盾的经典手段。今天这篇想连贯地聊聊Ruby CGI Session也就是在最朴素、最“古老”的CGI动态网页模式下用Ruby来管理用户会话的一整套方案。可能有人一听“CGI”就皱眉觉得这是上个世纪的玩具。但如果你在维护过时的管理系统、嵌入式设备的管理面板或者需要在不引入重量级框架的情况下快速出一个内部工具CGI配上Ruby反而轻巧得很。这篇文章会从无状态协议讲起到Session的创建、读写、销毁再到存储选型、安全加固和常见坑位尽量让刚接触的朋友少走弯路。1. 先搞清楚CGI环境下Session凭什么能工作1.1 HTTP无状态是原罪Session是妥协方案HTTP协议从诞生那天起就没打算记住用户。它的每个请求都是独立的服务器处理完就忘客户端也不会带着“身份档案”来——这就是所谓无状态。你可以把HTTP想象成一家没有记忆的餐厅客人推门进来服务员永远不知道他是熟客还是第一次来也不知道他上一个菜吃得满不满意。Session的解决办法是“服务端记录 客户端凭证”。你在服务端保存一份用户状态数据同时生成一个不容易猜到的编号Session ID让客户端下次带着编号前来。服务端一看编号就能从存储里把对应的状态数据翻出来仿佛失忆的人恢复了记忆。用生活化的类比Session ID就像自助餐厅入口发的号码牌你把包存在柜子里手里拿着牌子回头凭牌子取包。服务员不认识你没关系牌子认你就行。Cookie里放着这串号码牌服务端储物柜里放着你的包两边对上号状态就回来了。1.2 CGI模式下每次请求都是一次“轮回”CGI是动态网页最早的通用接口之一。Web服务器收到请求后为这个请求启动一个独立的外部程序进程比如Ruby脚本把请求参数通过环境变量和标准输入传进去再把程序的输出作为响应原样发回给客户端。这个模式下进程是“一次性”的脚本跑完进程消亡内存里的一切都烟消云散。所以你不能像常驻服务那样在全局变量里挂一个登录态。CGI程序要恢复会话唯一的路就是先从客户端拿到Session ID再去共享存储磁盘文件、数据库、memcached把数据读回来。这也解释了为什么CGI环境下Session设计得比较“重”——每个请求都要读一次存储、写一次存储天然比同进程内缓存慢。但这套机制稳定、简单尤其适合请求量不大但开发效率要求高的内部系统。理解了这一点你就明白为什么后面要专门讨论存储选型和垃圾回收。1.3 Ruby CGI::Session替你封装了什么Ruby标准库里的cgi/session模块说白了是把上面这套流程封装成你伸手就能用的API。它替你做了四件事自动识别客户端Cookie里的Session ID没有就生成一个新的把Session数据读写映射成类似Hash的访问方式会话结束时把改动的数据写回存储提供过期判断、销毁接口以及基础的垃圾回收逻辑。下面是初始化代码的骨架先有个印象第三节再展开参数require cgi require cgi/session cgi CGI.new session CGI::Session.new(cgi, session_key app_sid, new_session true)注意CGI::Session并不是把数据放在Cookie里而是只放一个Session ID。真正的业务数据存在服务端。这一点和某些框架的“签名Cookie方案”有本质区别后面安全章节会展开。2. 从零搭一个带Session的CGI应用2.1 环境准备开始动手之前先把底打牢。你需要Ruby建议2.6以上标准库已经包含cgi/session不需要额外装gem一个支持CGI的Web服务器比如Apache的mod_cgi或者用Ruby自带的WEBrick临时跑一个可写的会话目录通常是/tmp生产环境建议单独建目录并收紧权限。代码保存成.cgi或.rb加执行权限放到Web服务器指定的cgi-bin目录。本地测试时我自己习惯用WEBrick加一个简单映射几行代码就能跑起来require webrick server WEBrick::HTTPServer.new(Port: 8080, DocumentRoot: ./public) server.mount(/cgi-bin, WEBrick::CGIHandler, cgi-bin) trap(INT) { server.shutdown } server.start如果你用Apache也简单启用cgi模块把脚本放到cgi-bin确保shebang指向正确的ruby#!/usr/bin/env ruby然后chmod x脚本即可。CGI的执行环境就准备好了环境里的HTTP头、请求参数都会作为环境变量和标准输入交给Ruby程序。2.2 初始化Session搞清楚这几个参数CGI::Session的初始化典型写法如下session CGI::Session.new(cgi, { session_key app_sid, session_id cgi[app_sid], new_session false, tmpdir /var/lib/myapp/sessions, expire_after 1800 })逐个说session_keyCookie的名字。你可以理解成号码牌的款式全局统一即可session_id客户端随请求带来的ID。这里从cgi[app_sid]里拿其实就是从请求参数或Cookie里取new_session如果取不到session_id是报错还是自动新开一个会话。业务系统通常设成true避免用户非要带着ID才能访问tmpdir文件存储落地目录。默认走系统临时目录生产环境最好指定专门目录方便清理和权限管控expire_after空闲过期时间单位是秒。1800表示用户半小时不操作会话自动作废。这里有一个容易混淆的地方CGI::Session.new里传session_id时常见写法是从cgi[某个key]取值但Cookie里的值其实也会出现在请求参数里原理上是一致的。取不到时如果允许自动新建模块会自己生成一个不可预测的ID。经验之谈把new_session打开后不熟悉的人容易忘掉session_id的来历导致调试时满头雾水。建议先在浏览器开发者工具里看清楚名为app_sid的Cookie是否存在再判断是会话没建还是Cookie没带。2.3 读写与销毁访问Session数据像操作Hashsession[username] alice session[login_at] Time.now.to_s name session[username]值的类型需要注意CGI::Session经过序列化存储底层用Marshal或PStore保存。所以原则上Ruby对象都能放进去但别指望不同语言写的客户端能读懂。跨语言时建议只存字符串、数字这类简单类型。写完数据后记得调用closesession.closeclose会把内存里的改动写回存储并标记会话为“已关闭”。如果脚本即将结束不显式调用一般也会在进程收尾时自然落盘但显式调用更稳妥尤其是数据量较大时。销毁会话用destroysession.destroydestroy会同时清掉存储里的数据和客户端的Cookie适合登出场景。这里要特别提醒先销毁数据再输出重定向页面防止用户带着旧Cookie又“复活”一个会话。2.4 完整示例一个登录状态页面写一个最常见的场景——用户输入用户名登录然后访问受保护的页面一直保持登录状态。脚本login.cgi#!/usr/bin/env ruby require cgi require cgi/session cgi CGI.new session CGI::Session.new(cgi, session_key app_sid, tmpdir /var/lib/myapp/sessions) if cgi[action] logout session.destroy print cgi.header(type text/html; charsetutf-8) puts htmlbody已登出。/body/html exit end if cgi[username] !cgi[username].empty? session[username] cgi[username] end print cgi.header(type text/html; charsetutf-8) if session[username] puts 你好#{session[username]}a href?actionlogout退出/a else puts ~HTML form methodpost 用户名input nameusername / button typesubmit登录/button /form HTML end session.close逻辑很简单判断有没有提交用户名有就写进Session下次再来直接从Session里读。注意表单用POST提交CGI照样能从标准输入读到参数这个细节对新手来说很容易忽略。3. 存储方案与参数调优3.1 内置存储怎么选CGI::Session默认的文件存储FileStore把每个会话对应到一个文件文件名就是Session ID。它的优点是简单、不需要额外组件适合单机部署。代价是磁盘IO多文件多了之后目录里可能堆积成千上万个文件。标准库还提供了PStore存储。PStore是Ruby自带的持久化格式把数据结构序列化进单一文件。它适合数据量不大、并且愿意集中管理会话的场景但并发写入时容易因为文件锁而卡顿高并发场景不建议。如果你的服务器上本来就有Memcached可以接上内存存储。内存存储的读写速度和并发能力明显优于文件而且天然支持过期时间。不过CGI::Session的内置存储不一定覆盖所有后端建议安装memcache客户端库并实现一个简单的Store接口。关于自定义StoreRuby的CGI::Session允许多态替换核心接口只需要实现restore、update、close、delete几个方法。选型建议出一个表存储方案优点缺点适用场景FileStore默认零依赖、容易排查文件多、IO慢单机内部应用PStore数据集中、可读性好并发写有锁竞争小规模应用MemoryCache快、支持过期重启丢数据高并发短会话数据库持久、可共享依赖DB、略重多机负载均衡3.2 调优参数逐个拆解前面提到的参数只是基础实际生产中还经常用到几个prefix文件存储时给会话文件加统一前缀方便批量识别和清理no_hidden是否自动输出隐藏字段。CGI::Session在new_session模式下为了兼容禁用了Cookie的老浏览器会把Session ID塞进页面里的隐藏字段。现代Web应用建议设为true强制走Cookie减少安全面logger传一个Logger实例模块会输出调试信息。排查会话丢失问题时特别好用。设置方式都写在options哈希里session CGI::Session.new(cgi, session_key app_sid, tmpdir /var/lib/myapp/sessions, expire_after 3600, no_hidden true, logger logger )串起来看这套配置相当于一个叫app_sid的Cookie保存会话编号数据存指定的目录浏览器空闲一小时自动超时会话ID不往页面里塞出错时能看日志。3.3 会话垃圾回收不清理会爆目录CGI::Session的智能之处是自带延迟垃圾回收它不会每次请求都扫描所有会话文件而是按启动阈值定期触发清理把超过过期时间的文件删掉。问题在于“延迟”两个字。我见过不止一个系统把expire_after设成30分钟跑了一年Session目录里积累了上百万个文件。为什么因为很多脚本初始化Session之后根本没触发过关闭逻辑或者关闭逻辑只是把内存数据落盘但过期文件的清理间隔较长垃圾文件一直没被扫走。所以生产环境不能完全指望内置GC。我的做法是叠加两层保障第一层在应用层定期维护。比如借一次管理接口集中扫描tmpdir把mtime早于过期阈值的文件删掉第二层在系统层配置定时任务。比如每天凌晨清理一次find /var/lib/myapp/sessions -type f -mmin 180 -delete注意这条命令的前提是你能准确控制会话文件目录别随手用tmpdir默认目录否则误删别的进程文件就麻烦了。4. 安全加固Session不是存个号那么简单4.1 会话固定攻击黑客可以先自己拿到一个合法Session ID然后设法让受害者使用这个ID受害者登录后他手里没有感知的所有业务数据都写进了黑客可控的会话里这就是Session固定Fixing攻击。防御手段是“登录后换号”。用户登录成功那一刻把旧的Session销毁重新生成一个新的Session ID。CGI::Session的destroy之后重新new就能做到这一点。换号之后黑客手里的旧ID就作废了。4.2 Cookie安全属性Session ID这串钥匙尽量只通过Cookie传递而且要按最保险的方式声明HttpOnly禁止JavaScript读取Cookie杜绝XSS脚本偷IDSecure只允许HTTPS场景传输防止明文链路被截获SameSiteLax或Strict限制跨站请求带上Cookie减轻CSRF风险。CGI::Session默认生成的Cookie不一定替你把这些属性都设好。需要手动构造响应头或者在生成Cookie后单独追加属性。一个实际做法是在输出HTML前手动追加Cookies头cookie CGI::Cookie.new( name app_sid, value session.session_id, path /, expires Time.now 3600, secure true, httponly true, same_site Lax ) print cgi.header(cookie cookie)如果你用的反向代理已经处理了HTTPSsecure标志照样可以用因为客户端看到的还是HTTPS协议头。但要注意有些代理默认没开启X-Forwarded-Proto透传这时候设置secure反而会导致Cookie不落地出现“登录了一次就永远登录不上”的诡异现象。4.3 过期策略与来源校验Session的过期策略有两种常见模型固定超时从创建起算无论有没有活动到点就失效空闲超时每次有请求就重置倒计时用户一直在用就不过期。CGI::Session的expire_after更接近空闲超时。它会记录最后活动时间读取时判定是否超时。判定逻辑在不同存储实现里有差异所以生产环境里不要只在Session层依赖过期业务侧也要校验登录状态和时间戳。来源校验指的是确认请求确实来自同一个浏览器会话。基础做法是校验User-Agent的常见字段变化进阶做法是监控Session ID出现频率和地址变化。CGI项目通常没有现成中间件自己动手写一个简单的版本不难在Session里存首次访问的User-Agent、IP每次请求比对不一致就销毁会话提示重新登录。缺点是IP变化比如手机切WiFi会误伤正常用户建议只比对UA和一个粗粒度的来源段。4.4 别把敏感数据塞进Session的坑Session适合存“状态”和“开关”比如登录用户ID、权限标志、CSRF令牌。不适合存密码、银行卡号这种敏感数据。因为Session存储再安全也架不住落地文件的读取权限问题、备份文件的泄漏风险。敏感数据应该按业务单独加密存储Session里只留一个索引。这一点经常被忽略。有次我在检查一套内部系统发现它的Session文件里直接把用户密码的MD5都存了虽然那个系统处理逻辑本身没问题但这种数据分布方式一旦会话目录被目录遍历漏洞波及等于把钥匙和密码一起送人。所以我在自己的项目里都奉行一个原则Session里只放“需要跨请求记住的状态标记”敏感信息一律走单独存储。5. 常见问题与排障实录5.1 刷新以后登录态丢失这是出现频率最高的问题。排队排查顺序基本是先看浏览器开发者工具里有没有app_sid的Cookie没有Cookie多半是服务端没有正确输出Set-Cookie检查session初始化时机和header输出位置Cookie存在但刷新就变说明每次请求都在新建会话——检查session_id是否真的被读取。常见错误是从请求参数里找ID但Cookie键名在无请求参数时并不会自动进入params别在输出HTML之后才初始化Session因为CGI已经把响应头发完了Set-Cookie加不进去。5.2 写入Session报权限错典型报错类似“Permission denied”。基本原因就是Web服务器进程对tmpdir没有写权限或者目录被SELinux策略挡住。解决思路创建专门目录chown给运行Web服务器的用户权限控制在700或750别用777如果是RHEL系系统检查SELinux布尔值或者给会话目录设置正确的上下文。5.3 会话文件堆积导致磁盘满这类问题前面说过垃圾回收延迟导致。最直接的排查方法是看目录文件数量和磁盘占用ls /var/lib/myapp/sessions | wc -l du -sh /var/lib/myapp/sessions发现异常就先清一轮再排查是否有脚本忘记close。另外确认是不是有爬虫在疯狂发请求产生大量未完成的空会话。爬虫场景下建议直接把new_session设为false没带ID的直接拒绝省得为机器人建会话。5.4 多台机器部署会话时灵时不灵文件存储天然绑定本机。如果前面部署了负载均衡两台机器各存各的用户第一次请求落在A机器第二次落在B机器B机器找不到会话就会当成新用户处理。解决方案不外乎三种负载均衡开启会话粘滞Session Stickiness让同一个用户的请求始终落到同一台机器改用共享存储比如NFS挂载会话目录把存储切到数据库或Redis这类集中式后端彻底摆脱单机限制。我个人更推荐后两种粘滞方案虽然立竿见影但维护成本和故障面没减少。5.5 中文内容乱码CGI项目经常遇到响应头没有声明charset导致浏览器猜编码Session里的中文被Marshal序列化也容易在显示时出问题。统一方案是脚本文件保存为UTF-8输出时声明charsetprint cgi.header(type text/html; charsetutf-8)存储和读取的字段做好encode校验别混入其他编码。否则你会发现在Shell里跑得好好的脚本通过浏览器一访问就变乱码调试半天才发现是编码声明丢了。刚才那条“登录一次就再也登录不进去”的坑我至今印象很深。当时新换的反向代理方案把协议透传标志丢了Secure Cookie在测试环境永远出不去排查了整整一下午。最后把代理配置修好Cookie正常落地一切恢复。所以如果你也在维护老掉牙但能跑的CGI系统最值得投入的不是换框架而是把Session这条链路里头的细节一一确认过。Cookie属性、权限、过期、清理任何一环出问题表现都像“系统抽风”。这套东西虽然不上台面但它解决问题的思路——服务端状态加客户端凭证——到今天依然是所有Web框架的基石。理解了CGI的Session你再去看那些高大上的会话中间件会发现内核长得一模一样。行了今天先聊到这愿你的会话永不丢失。