背景
昨天遇到一个问题,用户登陆打印后台管理后,点击其他菜单会自动退出,跳转到登陆页面
排查
经过查看请求日志发现确实每次登陆后,再浏览其他页面时会自动跳转到登陆页。
回忆下项目中关于后台登陆的相关实现:
- 管理后台使用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不一致。如图:
Pasted image 20231130115249.png
知道问题就简单多了,sse由于不需要保存会话,完全可以不在结束的时候写入session。比如在请求开始seesion read后就直接将 session 设置为nil
context.Input.CruSession = nil
那为什么不在session write的时候加锁,或者写之前再读一次呢?
个人认为锁会影响性能,如果改为乐观锁,则作为框架怎么知道哪个版本的session是旧的,哪个是新的呢,除了开发者没人知道,所以beego在session初始化的时候是没有的,只在session存储kv对的时候做了读写锁的,为什么不写之前再读也是同样的道理。
2024年4月29日
根据上面的办法解决问题后,在随后的一段时间内基本上没有再出现过登录后再退出的问题,本以为这个问题就这么解决了,但在最近频繁测试的过程中发现,偶尔还是会出现登录后再退出的问题。
- 自上次解决后首次出现该问题时,想着那我只需要知道在登录前和登录后的session是什么,就可以解决该问题,接下来就进数据库查看session表(我们的session是存在数据库中),然后发现数据库登录不了,提示
too many files
,这一看就是数据库连接被耗光了,立即想到是不是还是与sse有关,但看了下代码,又觉得没问题。sse不会在保存session,就想会不会有其他请求写了session,因为特别不好复现,所以简单打了个读session和写session的日志,就等着复现了,随即这个问题就暂时搁置了 - 等过了一周后在用户现场又出现该问题了,🉐️,这次不得不解决了,开始根据之前的日志查看是不是有其他请求影响了,把登录前的请求都摘出来. 如下:
http://127.0.0.1:9000/user/admin/login
http://127.0.0.1:9000/umi.css
http://127.0.0.1:9000/umi.js
http://127.0.0.1:9000/vendors.chunk.css
http://127.0.0.1:9000/vendors.async.js
http://127.0.0.1:9000/layouts__UserLayout.chunk.css
http://127.0.0.1:9000/layouts__UserLayout.async.js
http://127.0.0.1:9000/manifest.json
http://127.0.0.1:9000/android-icon-144x144.png
http://127.0.0.1:9000/p__user__login__model.js.async.js
http://127.0.0.1:9000/p__user__login.chunk.css
http://127.0.0.1:9000/p__user__login.async.js
https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg
http://127.0.0.1:9000/api/v3.1/client-package/links?
http://127.0.0.1:9000/api/v3.1/sse
http://127.0.0.1:9000/api/v3.1/me?
http://127.0.0.1:9000/favicon-32x32.png
http://127.0.0.1:9000/auth/token?username=secadmin
http://127.0.0.1:9000/auth/signin?
按照 controller 分
静态页面和其他文件;sse;
/api/v3.1/client-package/links
/api/v3.1/sse
/auth/token
/auth/signin
- 关闭除了登录的最后2个请求之外的所有请求后,发现问题依然存在,且因为很难复现,所以得出的规律好像是登录页一直存在,很久之后偶尔就会出现一次,自发现后我们在本地总共复现了3次。
- 这个问题还是我的同事发现的,因为我之前出现该问题时,存在过too many files,所以他就想看看数据库连接数,于是先登录上数据库客户端,然后等问题出现的时候,查下连接数,这一看就发现连接数隔一会儿就增加了,此时登录页面上只有sse请求。于是去再次去查看session模块的代码。
- 通过session代码得知,beego在管理session时,每个请求进来read session前会打开一个新的数据库连接,请求结束后 write session后再关闭掉这个数据库连接。它是自己管理数据库连接的,到这里问题就算是彻底搞清楚了,前面查的时候方向就错了,如果第一次出现too many files时能好好追一下,后来也许不会耽搁这么久的时间。
// SessionRead get mysql session by sid
func (mp *Provider) SessionRead(sid string) (session.Store, error) {
fmt.Println("session read sid ", sid)
// 连接数据库
c := mp.connectInit()
row := c.QueryRow("select session_data from "+TableName+" where session_key=?", sid)
var sessiondata []byte
err := row.Scan(&sessiondata)
if err == sql.ErrNoRows {
c.Exec("insert into "+TableName+"(`session_key`,`session_data`,`session_expiry`) values(?,?,?)",
sid, "", time.Now().Unix())
}
var kv map[interface{}]interface{}
if len(sessiondata) == 0 {
kv = make(map[interface{}]interface{})
} else {
kv, err = session.DecodeGob(sessiondata)
if err != nil {
return nil, err
}
}
fmt.Println("session read values ", kv)
rs := &SessionStore{c: c, sid: sid, values: kv}
return rs, nil
}
// SessionRelease save mysql session values to database.
// must call this method to save values to database.
func (st *SessionStore) SessionRelease(w http.ResponseWriter) {
// 关掉数据库连接
defer st.c.Close()
fmt.Println("release session", st.values, "sid", st.sid)
b, err := session.EncodeGob(st.values)
if err != nil {
return
}
st.c.Exec("UPDATE "+TableName+" set `session_data`=?, `session_expiry`=? where session_key=?",
b, time.Now().Unix(), st.sid)
}
- 所以上面的将当前session设置为nil,让请求结束的时候不要保存session是错误的,这样会造成内存泄露,因为sse每一次请求等于只打开了数据库连接,因为不保存session,数据库连接一直没关闭,时间久了,连接数被耗尽,读不到session,自然就退出登录页了。
- 目前我们的解决办法就是在beego的配置文件里关闭掉session,然后再需要session的请求前的中间件里read session,结束后write session