在几个月之前,我将每课 server 的 session 存储位置从 Flask 默认的客户端迁移到了服务器端,使用到的是 Flask-session 这个包,后端存储选用的是 MongoDB。近些日子以来,网站打开速度越来越慢,通过 APM 的报表中可以看到写入 session 这一步最多的时候竟然需要花费 40 秒之巨!看来需要一点措施来解决问题了。

问题起源

如果你还是一个 Flask 新手,我简要的说一下:在你使用类似 session["some"]="value" 语句写入 session 的时候,Flask 会使用配置文件中的 SECRET 字段进行对 session 键值对进行签名 (注意,不是加密,是签名!),然后在客户端种植一个名为session 的 Cookie。这样的客户端 session 使用上非常简单,因为服务器端不需要维护状态,但是缺点也很明显:用户可以看到 session 的全部内容(尽管不能篡改内容),如果需要存一些敏感信息的话就不能使用这种方式。

基于这样的背景,因为业务需求不太希望用户看到 session 内容,在几个月之前我选用了 Flask-session 这个包将 session 的位置转移到服务器后端的 MongoDB 上。过程非常简单,基本上就是在配置文件里加了几个参数然后在初始化的时候 init 了一下。官方文档也没有说很多,我也就觉得应该不会有问题(有坑至少提醒一下吧)。然而这就是坑的开始。

踩坑

近期用户反映网站越来越慢,因为我最近在对 MongoDB 的内存使用做限制,所以觉得可能只是因为限制了内存的使用导致网站稍微慢一点。然而当我自己使用的时候出现 502,我才觉得有点问题了。

去 APM 看了一下瓶颈,发现时间几乎全部花在 MongoDB 的 session 写入上。去数据库里一看,十万多条 session、没有索引、过期了也不会自动删除,怎么会不慢呢?

解决

具体的命令我没有留下来,大家可以自行面向谷歌,主要说说思路:

  • 首先是删除过长的 id。长度超出 UUID 的长度则是过长。这些是客户端 session 时代的遗留产物,flask-session 没有将他们替换成统一的 UUID,而是新用户使用 UUID 作为 session 的唯一标识符,老用户继续使用长度可能非常长的客户端 session 字符串作为 session 的唯一标识符。这样的混用导致了我们后面会因为部分 key 长度过长而无法建立索引。
  • 删除过期的 session。你可以看到表中每一条记录都有一个过期时间,然而 flask-session 并没有提供一个计划任务来定期删除,而是当此用户再一次访问的时候判断过期了才会删除(然而大量的用户和爬虫可能很长时间不会用同一个 session 访问第二次)。我这边在执行了这一步之后,数据条数减少了一半。还是非常明显的。
  • 建立索引。为 id 建立索引,让查找速度更快。

通过以上几个步骤,响应时间已经出现大幅回落,回到了正常水平。然而我们仍然需要注意几个问题:

  • 过期 session 一直残留在数据库中的问题会一直存在,flask-session 没有提供任何机制来解决,可能需要你自己写一个计划任务
  • 部分用户会尝试继续使用超长的客户端 session 来向服务器发送请求,如果不做处理的话会因为 MongoDB 索引约束导致插入失败。对此我的策略是在请求前检查 cookie 的 session 长度,如果超长的话直接清除 session(如果你觉得清除 session 对业务有影响的话也可以写更复杂的逻辑去平滑过渡,但是我们的业务没有如此严格的要求):
    @app.before_request
    def delete_old_session():
        """删除旧的客户端 session(长度过长导致无法在 mongodb 中建立索引)"""
        if request.cookies.get("session") and len(request.cookies.get("session")) > 36:
            session.clear()
            return redirect(request.url)

后记

当初用的时候也是没想到后续居然会有这么大的坑,只能说官方文档不够健全。在开发过程中遇到有坑的包,有时不得不采用猴子补丁,但猴子补丁会在将来某次升级的时候引发不兼容,因此升级依赖的时候还要小心检查。因此比较理想的情况还是给作者提 issue 和 PR,从源头解决问题。