记一次长连接导致的session不一致
2023-11-29 本文已影响0人
小东班吉
背景
昨天遇到一个问题,用户登陆打印后台管理后,点击其他菜单会自动退出,跳转到登陆页面
排查
经过查看请求日志发现确实每次登陆后,再浏览其他页面时会自动跳转到登陆页。
回忆下项目中关于后台登陆的相关实现:
- 管理后台使用beego,基本上只使用了它的路由以及session管理
- 登陆相关有jwt和session,而我们管理后台登陆仍然使用的是session,所以jwt可以排除了
- session的实现包含了manage,store,provider 3个interface,以及store接口的不同实现对象,我们使用的是mysql存储。那一般访问session通过manage,找到provider,然后调用sessionStore的具体实现
- session是存储在数据库当中的,三个字段,分别是seesionId,sesssionData,sessionExpiry
- sessionId存储在cookie当中,在每次请求进来时会调用sessionRead,开启或者恢复上次会话,请求结束的时候会保存当前的会话以做下次请求恢复
- sessionId是存储在cookies当中的,每次请求会带上,用来操作session数据,CruSession在这里就是session store(beego为啥不统一用session manage来管理呢),源码如下:
func (p *ControllerRegister) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
if BConfig.WebConfig.Session.SessionOn {
var err error
context.Input.CruSession, err = GlobalSessions.SessionStart(rw, r)
//...
defer func() {
if context.Input.CruSession != nil {
context.Input.CruSession.SessionRelease(rw)
}
}()
}
}
分析:
- 登陆后到再跳转到登陆页的所有请求都有携带sessionId
- 登陆服务端没有任何错误出现,session存储访问也正常,过期时间等都正常。
- 通过1和2可以猜测可能有其他请求把session覆盖了或者清空了
- 我们有个sse的长连接,主要利用了sse服务端断开后,客户端会重新发起新的连接连接服务端的特性,实现了当服务端更新后,sse的error事件触发后再重新reload服务端页面。
- 查看代码sse的请求是不经过登陆的,在登陆前就会初始化并连接到服务端,登陆后页面跳转了,在跳转前组件卸载的时候会调用sse.close关闭长连接
看起来问题已经比较明了,sse引起的,一般我们登陆都是服务端存储session后告诉客户端,客户端接下来跳转页面,跳转页面时sse.close事件触发,服务端会收到客户端断开连接的通知,然后退出,代码如下。
客户端:
export default function useConnectionDetect() {
useEffect(() => {
const sse = new EventSource(`${endpoint}/sse`);
let retries = 0;
let down = false;
sse.onopen = () => {
console.log(`connection established. retry: ${retries}, down: ${down}`);
retries = 0;
if (down === true) {
window.location.reload();
}
};
sse.onerror = () => {
if (retries < MAX_RETRIES) {
console.log(`connection lost, retrying(${retries})...`);
retries += 1;
return;
}
withClient(({ context }) => {
context.notification({
title: '安全审计系统',
content: '服务器连接失败,请检查网络连接或联系管理员',
});
});
console.error('server is down');
down = true;
};
return () => {
console.log(`closing connection...`);
sse.close();
};
}, []);
}
服务端:
for {
select {
case <-this.Ctx.ResponseWriter.CloseNotify():
debug.DebugThunk(func() {
logs.Debug("客户端主动关闭了 SSE 链接")
})
return
case <-timer.C:
logs.Debug("timeout")
return
default:
logs.Debug("send")
data := fmt.Sprintf("data: %s\nretry: %d\n\n", "pong!", retryInterval*1e3)
this.Ctx.ResponseWriter.Write([]byte(data))
this.Ctx.ResponseWriter.Flush()
time.Sleep(1 * time.Second)
}
}
sse在请求开始的时候会session read,然后在请求结束的时候session write,登陆前sse请求开始时已经获取了一份session read 存了起来,登陆后存储了一份新的session,而sse断开连接是发生在登陆后,sse在请求结束的时候调用了session write,写了一份空的session数据进去,覆盖了登陆存储的session,从而导致了session不一致。如图:
![](https://img.haomeiwen.com/i8039934/14c78324001d00d1.png)
知道问题就简单多了,sse由于不需要保存会话,完全可以不在结束的时候写入session。比如在请求开始seesion read后就直接将 session 设置为nil
context.Input.CruSession = nil
那为什么不在session write的时候加锁,或者写之前再读一次呢?
个人认为锁会影响性能,如果改为乐观锁,则作为框架怎么知道哪个版本的session是旧的,哪个是新的呢,除了开发者没人知道,所以beego在session初始化的时候是没有的,只在session存储kv对的时候做了读写锁的,为什么不写之前再读也是同样的道理。