基于 Redis 实现 Laravel 分布式 Session 存取及底层源码探究


Session 存储器选择

Laravel 没有使用 PHP 内置的 Session 功能,而是自行实现了一套 Session 组件,和其他 Laravel 系统组件一样,Session 组件也支持多种驱动作为存储器实现,包括文件、数据库、Memcached、Redis 等,默认使用的是文件驱动:

SESSION_DRIVER=file

如果应用只部署在一台服务器上,使用文件驱动就够了,但是如果采用集群部署,文件驱动就有问题了:比如用户登录请求被负载均衡到服务器 A 上,Session 数据就会存储到这台机器的 Session 文件中,而该用户的下一个请求被负载均衡到了服务器 B 上,在那台机器上的 Session 文件中找不到对应的 Session 数据,就会导致用户明明已经登录,但是读取不到 Session 数据而显式未登录的情况,这就是 Bug 了。

为了解决这个问题,需要将 Session 数据存储到所有应用集群机器可以共享的存储媒介,比如数据库、Memcached、Redis 等,数据库在应对高并发请求的时候性能不及 Memcached、Redis 等基于内存的存储媒介,而 Memcached 作为缓存系统是没的说,但是不支持数据持久化,系统重启后所有数据就都丢了,所以最佳选择就剩下 Redis。

使用 Redis 作为分布式 Session 存储器后,所有应用集群内的机器可以到统一的 Redis 服务实例存取 Session 数据,就不会再有文件驱动那种「各人自扫门前雪,休管他人瓦上霜」的数据孤岛情况出现了。

Laravel Session 使用入门

基本配置

要使用 Redis 作为 Laravel Session 的存储器驱动,只需要将环境配置文件 .env 中的 SESSION_DRIVER 配置为 redis 即可:

SESSION_DRIVER=redis

Laravel Session 数据的默认有效期是 120 分钟,即两个小时,要修改这个值,可以修改 SESSION_LIFETIME 配置值:

SESSION_LIFETIME=120

更多 Session 配置,可以到 config/session.php 中查看和维护。

存储&获取

在 Laravel 中,你可以通过多种方式获取 Session 实例对 Session 数据进行管理。

通过 session 辅助函数

$name = session('name', function () {
    return session('name', '学院君');
});
dd($name);

通过 Session 门面

$name = Session::get('name', fn() => Session::put('name', '学院君'));
dd($name);

通过 Request 实例

$request->session()->put('name', '学院君');
$name = $request->session()->get('name');
dd($name);

Session 的基本使用非常简单,更多使用语法,可参考 Laravel Session 文档

底层实现源码分析

接下来,我们来看看 Laravel 底层是如何基于 Redis 实现 Session 数据的管理的。

Session 服务注册和驱动器实例

不管是 Session 门面还是辅助函数,底层都是通过绑定在服务容器中的 session 服务实例对 Session 数据进行管理的:

-w761

服务注册源码位于 Illuminate\Session\SessionServiceProvider

当我们调用 putget 方法存取 Session 数据时,实际上调用的都是这个 SessionManager 上的方法,但继续追根溯源,会发现 SessionManager 也没有定义这些方法,而是通过其父类定义的魔术方法 __call 调用 $this->driver() 返回的 Session 驱动实例上的方法:

-w756

这个 $this->driver() 方法会读取配置文件中配置的 Session 驱动,这里是 redis,最终通过 createRedisDriver 方法,调用 $this->createCacheHandler('redis') 创建基于 Redis 驱动的 CacheBasedSessionHandler 来构建 Session 存储器,并将其作为最终的 Session 驱动实例:

-w746

所有上层业务调用的 Session 数据管理操作最终都是通过这个实例执行的,但它也是一个封装了 CacheBasedSessionHandler 的壳,我们前面介绍的不同驱动实现都位于 SessionHandler(处理器)这一层,Session 存储器是在其基础上做了统一的封装,最终数据的读取、存储是通过 SessionHanlder 与更底层的文件、数据库、缓存驱动打交道的。

Session 处理器与存储器驱动实现

下面我们就以 Redis 驱动为例来深入分析下 Session 处理器和存储器的底层实现。

CacheBasedSessionHandler 实现了 PHP 提供的 SessionHandlerInterface 接口来自定义兼容 PHP 官方约定的 Session 处理器:

<?php

namespace Illuminate\Session;

use Illuminate\Contracts\Cache\Repository as CacheContract;
use SessionHandlerInterface;

class CacheBasedSessionHandler implements SessionHandlerInterface
{
    protected $cache;

    protected $minutes;

    public function __construct(CacheContract $cache, $minutes)
    {
        $this->cache = $cache;
        $this->minutes = $minutes;
    }

    public function open($savePath, $sessionName)
    {
        return true;
    }

    public function close()
    {
        return true;
    }

    public function read($sessionId)
    {
        return $this->cache->get($sessionId, '');
    }

    public function write($sessionId, $data)
    {
        return $this->cache->put($sessionId, $data, $this->minutes * 60);
    }

    public function destroy($sessionId)
    {
        return $this->cache->forget($sessionId);
    }

    public function gc($lifetime)
    {
        return true;
    }
    
    public function getCache()
    {
        return $this->cache;
    }
}

其中的 cache 属性对应的是基于 Redis 实现的缓存服务实例,所以说,基于 Redis 的 Session 处理器最终是通过缓存组件管理Session 数据的,对于缓存系统而言,有自己的一套数据过期管理机制,所以 gc 方法可以留空。

回到 createRedisDriver 方法,通过最后一行代码,进入 buildSession,看看基于 Session 存储器的 Session 驱动最终是如何实现的:

-w805

在这个方法中,由于默认的 session.encrypt 被配置为 false,所以最终返回的是通过 Cookie 名称和 Redis 缓存服务驱动的 Session 处理器构建的 Illuminate\Session\Store 实例,它是最终对 Session 服务进行管理的 Session 驱动实现。

阅读 Illuminate\Session\Store 源码,不难看出 Laravel 应用 Session 服务的启动、Session 数据的管理、一次性数据存取都是通过它实现的。

Session 数据存取的底层原理

以 Session 存取方法 getput 实现为例,当用户发起一个 Web 请求后,首先会通过 StartSession 中间件(web 中间件组中注册)中的 startSession 方法开启 Session 服务:

-w894

这个方法最终调用的是 Session 驱动实例 Illuminate\Session\Storestart 方法:

public function start()
{
    $this->loadSession();

    if (! $this->has('_token')) {
        $this->regenerateToken();
    }

    return $this->started = true;
}

该方法会调用 loadSession 方法从 Session 处理器中合并存储在 Redis 缓存中的当前用户 Session 数据到当前驱动实例的 Session 属性数组 $attributes 中:

-w852

注:Redis 缓存系统会在键名中通过 Session ID 对不同用户的 Session 数据进行区分,而 Session ID 又会每次随着请求 Cookie 传递过来,StartSession 中间件初始化 Session 驱动实例时会基于 Cookie 中读取到的值来设置 Session ID,如果没有的话,会重新设置,所以上述合并只会合并当前用户的 Session 数据,由此也确保了 HTTP 请求变得有状态(Sateful)其不会出现不同用户的 Session 数据错乱。

这样一来,当我们在请求处理代码中通过 Session 存储器的 get 方法获取 Session 数据时,就可以读取到之前该用户请求设置过的 Session 数据了:

public function get($key, $default = null)
{
    return Arr::get($this->attributes, $key, $default);
}

下面我们再来看 Session 数据的存储实现:

public function put($key, $value = null)
{
    if (! is_array($key)) {
        $key = [$key => $value];
    }

    foreach ($key as $arrayKey => $arrayValue) {
        Arr::set($this->attributes, $arrayKey, $arrayValue);
    }
}

设置 Session 数据时,先直接写入驱动实例的 $attributes 属性数组,因此,在当前请求周期内读取时,可以直接从这个属性数组里面获取。对于 Session 有效期内的后续请求,在当前请求处理完毕发送响应数据给用户前,会通过 StartSession 中间件进行后续 Session 持久化处理。

回到 StartSession 中间件的 handleStatefulRequest 方法,在请求处理之后,会先通过 addCookieToResponse 方法添加包含 Session ID 的 Cookie 到响应头,然后通过 saveSession 方法保存当前 Session 数据到 Redis 缓存,该方法最终调用的是 Session 驱动实例 Illuminate\Session\Storesave 方法:

-w776

也就是将当前用户 Session ID 对应的 Session 数据(位于 $attributes 属性数组)通过 Session 处理器实例持久化到 Redis 缓存中。

这样一来,下次请求时又会从处理器中合并这些 Session 数据到当前驱动实例的 $attributes 数组,从而 get 方法获取,周而复始。

Session 数据过期维护

这里,还有一个问题,就是 Session 数据不是有个生命周期吗,这是如何维护的呢?

原来,在存储 Session 数据到 Redis 缓存时,会读取 Session 生命周期时长作为对应键名的有效期,然后通过 Redis 本身的机制即可自动销毁过期的 Session 数据。对于那些不支持这种自动清除过期数据的 Session 驱动,比如文件、数据库,在 StartSession 中间件每次启动 Session 服务后,请求处理前,会调用 collectGarbage 方法清理已过期的 Session 数据:

-w942

以数据库驱动处理器 DatabaseSessionHandler 为例,对应的 gc 实现方法如下:

public function gc($lifetime)
{
    $this->getQuery()->where('last_activity', '<=', $this->currentTime() - $lifetime)->delete();
}

这是一种惰性清理机制,如果用户没有发起请求,则不会清除,所以你可以通过编写调度任务及时清理这些过期数据,以免垃圾数据堆积。

小结

以上就是 Laravel 底层 Session 服务从启动、到数据存储、读取、清理的完整实现,到这里,我们也已经演示和分析完了 Laravel 底层的所有系统组件,Redis 的实战入门篇也已经进入尾声。下篇教程,学院君将开始给大家介绍 Redis 的进阶应用和 Redis 自身功能的底层实现原理。

本系列教程的源码可以从 Github 获取:https://github.com/nonfu/redis-demo/


点赞 取消点赞 收藏 取消收藏

<< 上一篇: 基于 Redis 消息队列实现 Laravel 邮件通知的异步发送

>> 下一篇: 安全地使用 Redis(上):端口安全、指令安全和内存使用限制