记一次长连接导致的session不一致

背景

昨天遇到一个问题,用户登陆打印后台管理后,点击其他菜单会自动退出,跳转到登陆页面

排查

经过查看请求日志发现确实每次登陆后,再浏览其他页面时会自动跳转到登陆页。
回忆下项目中关于后台登陆的相关实现:

  1. 管理后台使用beego,基本上只使用了它的路由以及session管理
  2. 登陆相关有jwt和session,而我们管理后台登陆仍然使用的是session,所以jwt可以排除了
  3. session的实现包含了manage,store,provider 3个interface,以及store接口的不同实现对象,我们使用的是mysql存储。那一般访问session通过manage,找到provider,然后调用sessionStore的具体实现
  4. session是存储在数据库当中的,三个字段,分别是seesionId,sesssionData,sessionExpiry
  5. sessionId存储在cookie当中,在每次请求进来时会调用sessionRead,开启或者恢复上次会话,请求结束的时候会保存当前的会话以做下次请求恢复
  6. 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)  
           }  
        }()  
    }
}

分析:

  1. 登陆后到再跳转到登陆页的所有请求都有携带sessionId
  2. 登陆服务端没有任何错误出现,session存储访问也正常,过期时间等都正常。
  3. 通过1和2可以猜测可能有其他请求把session覆盖了或者清空了
  4. 我们有个sse的长连接,主要利用了sse服务端断开后,客户端会重新发起新的连接连接服务端的特性,实现了当服务端更新后,sse的error事件触发后再重新reload服务端页面。
  5. 查看代码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日

根据上面的办法解决问题后,在随后的一段时间内基本上没有再出现过登录后再退出的问题,本以为这个问题就这么解决了,但在最近频繁测试的过程中发现,偶尔还是会出现登录后再退出的问题。

  1. 自上次解决后首次出现该问题时,想着那我只需要知道在登录前和登录后的session是什么,就可以解决该问题,接下来就进数据库查看session表(我们的session是存在数据库中),然后发现数据库登录不了,提示too many files,这一看就是数据库连接被耗光了,立即想到是不是还是与sse有关,但看了下代码,又觉得没问题。sse不会在保存session,就想会不会有其他请求写了session,因为特别不好复现,所以简单打了个读session和写session的日志,就等着复现了,随即这个问题就暂时搁置了
  2. 等过了一周后在用户现场又出现该问题了,🉐️,这次不得不解决了,开始根据之前的日志查看是不是有其他请求影响了,把登录前的请求都摘出来. 如下:
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
  1. 关闭除了登录的最后2个请求之外的所有请求后,发现问题依然存在,且因为很难复现,所以得出的规律好像是登录页一直存在,很久之后偶尔就会出现一次,自发现后我们在本地总共复现了3次。
  2. 这个问题还是我的同事发现的,因为我之前出现该问题时,存在过too many files,所以他就想看看数据库连接数,于是先登录上数据库客户端,然后等问题出现的时候,查下连接数,这一看就发现连接数隔一会儿就增加了,此时登录页面上只有sse请求。于是去再次去查看session模块的代码。
  3. 通过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)
}
  1. 所以上面的将当前session设置为nil,让请求结束的时候不要保存session是错误的,这样会造成内存泄露,因为sse每一次请求等于只打开了数据库连接,因为不保存session,数据库连接一直没关闭,时间久了,连接数被耗尽,读不到session,自然就退出登录页了。
  2. 目前我们的解决办法就是在beego的配置文件里关闭掉session,然后再需要session的请求前的中间件里read session,结束后write session
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,122评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,070评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,491评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,636评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,676评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,541评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,292评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,211评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,655评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,846评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,965评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,684评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,295评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,894评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,012评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,126评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,914评论 2 355

推荐阅读更多精彩内容