Laravel 5.1 Redis 缓存配合 MySQL 数据库实现「用户最后活跃时间」功能

前言

今日给 PHPHub 开发了记录用户「最后活跃时间」的小功能,现在趁着热乎将相关实现逻辑写出来,欢迎大家指出不足。

file

基本思路

  1. 通过 Middleware 捕获用户的所有请求;
  2. 记录用户的请求时间,将数据放入缓存 (Redis) 中;
  3. 通过 Cron 每十分钟将缓存 (Redis) 里的数据同步到数据库中;
  4. 读取数据:先从 Redis 中读取数据,如 Redis 中没有,则从数据库中获取数据并同步到 Redis 中。

下文中我将针对每一个步骤进行详细说明。

捕获用户的所有请求

只有捕获用户的所有请求,我们才能实时的记录用户的最后活跃时间。在这里我们通过 中间件 来实现此逻辑。以下是生成中间件的具体操作:

1). 运行以下命令,生成 RecordLastActivedTime 中间件。

php artisan make:middleware RecordLastActivedTime

2). 在 app/Http/Kernel.php 中添加以下配置,将 RecordLastActivedTime 设置为全局中间件。

protected $middleware = [
   ...
   \App\Http\Middleware\RecordLastActivedTime::class,
];

3). 在 app\Http\Middleware\RecordLastActivedTime.php 文件中添加逻辑代码

...

use Closure;
use Auth;

class RecordLastActivedTime
{
    public function handle($request, Closure $next)
    {
        if (Auth::check()) {
            // 在这里记录用户的访问时间,下文将介绍此方法。
            Auth::user()->recordLastActivedAt();
        }

        return $next($request);
    }
}

这样登录用户在访问我们网站任何一个页面时,都会进入到 RecordLastActivedTime 中,接下来我们将处理「记录用户请求时间」的逻辑。

记录用户的请求时间,将数据放入 Redis 中

现在,我们需要将用户的「最后活跃时间」存放到 Redis 中,并且接下来的读写操作都在 Redis 里进行,以减少数据库的开销。下面是具体操作:

1). 在 app/Models/User.php 中添加以下方法:

public function recordLastActivedAt()
{
    $now = Carbon::now()->toDateTimeString();

    // 这个 Redis 用于数据库更新,数据库每同步一次则清空一次该 Redis 。
    $update_key = 'actived_time_for_update';
    $update_data = Cache::get($update_key);
    $update_data[$this->id] = $now;
    Cache::forever($update_key, $update_data);

    // 这个 Redis 用于读取,每次要获取活跃时间时,先到该 Redis 中获取数据。
    $show_key = 'actived_time_data';
    $show_data = Cache::get($show_key);
    $show_data[$this->id] = $now;
    Cache::forever($show_key, $show_data);
}

actived_time_for_updateactived_time_data 的数据结构如下:

array:4 [
  1 => "2016-07-31 16:05:44"
  2 => "2016-07-31 16:04:48"
  3 => "2016-07-30 22:06:48"
  4 => "2016-07-29 08:04:11"
]

2). 在 RecordLastActivedTime 中间件中调用 recordLastActivedAt

class RecordLastActivedTime
{
    public function handle($request, Closure $next)
    {
        if (Auth::check()) {
            Auth::user()->recordLastActivedAt();
        }

        return $next($request);
    }
}

至此,只要登录的用户每访问一次网站,都会将其「最后活跃时间」记录到 Redis 中。

定期将 Redis 里的数据同步到数据库中

完成了上两步操作后,现在已经能获取到用户的「最后活跃时间」了,不过为了保证数据的完整性,我们需要定期将 Redis 数据同步到数据库中,否则一旦 Redis 出问题或者执行了 Redis 清理操作,用户的「最后活跃时间」将会丢失。

我的方案是编写一个同步命令 SyncUserActivedTime,然后在计划任务里设置每 10 分钟运行该命令。以下是具体操作:

1). 运行以下命令添加 console

php artisan make:console SyncUserActivedTime --command=phphub:sync-user-actived-time

2). 在 app\Console\Commands\SyncUserActivedTime 添加逻辑代码


...

use Illuminate\Console\Command;
use App\Models\User;
use Cache;

class SyncUserActivedTime extends Command
{
    protected $signature = 'phphub:sync-user-actived-time';

    protected $description = 'Sync user actived time';

    public function __construct()
    {
        parent::__construct();
    }

    public function handle()
    {
        // 注意这里获取的 Redis key 为 actived_time_for_update
        // 获取完以后立马删除,这样就只更新需要更新的用户数据
        $data = Cache::pull('actived_time_for_update'));
        if (!$data) {
            $this->error('Error: No Data!');
            return false;
        }

        foreach ($data as $user_id => $last_actived_at) {
            User::query()->where('id', $user_id)
                         ->update(['last_actived_at' => $last_actived_at]);
        }

        $this->info('Done!');
    }
}

3). 在 app/Console/Kernel.php 添加以下配置,生成 计划任务

protected $commands = [

    ...

    // 注册命令
    Commands\SyncUserActivedTime::class,
];

protected function schedule(Schedule $schedule)
{
    ...

    // 设置为每 10 分钟运行一次该命令。
    $schedule->command('phphub:sync-user-actived-time')->everyTenMinutes();
}

获取用户的「最后活跃时间」

在 UserPresenter 添加以下方法

public function lastActivedAt()
{
    $show_key  = 'actived_time_data';
    $show_data = Cache::get($show_key);

    // 如果 Redis 中没有,则从数据库里获取,并同步到 Redis 中
    if (!isset($show_data[$this->id])) {
        $show_data[$this->id] = $this->last_actived_at;
        Cache::forever($show_key, $show_data);
    }

    return $show_data[$this->id];
}

然后在需要展示的页面调用 lastActivedAt 即可,例如:

User::find(1)->present()->lastActivedAt;

如果你没有使用 present,可以将此方法写到 app/Models/User.php 中。

以上。

本帖已被设为精华帖!
本帖由 Summer 于 7年前 加精
monkey
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 16

多谢分享,读取数据那块很巧妙

7年前 评论

Redis中的key,如果有很多的话,感觉最好不要硬编码,写个类用const常量来枚举,更利于以后管理和开发

7年前 评论
TimJuly

怎么能用cache呢,cache一般是读多写少,你这样在高并发的情况下会大量丢数据的.

7年前 评论

@TimJuly 我同意你的看法。

这块貌似没必要这么做,咱们分析一下

  1. 业务的需求就是写的特别频繁
  2. 因为你这块放到中间件了,不影响后续的程序执行速度(写多了表慢锁表)

如果真的想提高效率,我建议这样

  1. 这跟主逻辑无关的弄到 nosql 数据库中,nosql 的读写是关系数据库无法比拟的,并且你这块没什么涉及其他的逻辑,可以拆出去
  2. 设置成异步的(队列形式)去处理,不能因为中间件慢影响后面的操作和请求的返回
  3. 中间件建议使用 @Summer 说的后置中间件的方式 https://phphub.org/topics/2018

不过你的思路还是很好的。一个中间件搞定所有请求 :+1:

7年前 评论
monkey

@zhuzhichao Redis 本身就是 NoSQL :

Redis has also been ranked the #1 NoSQL (and #4 database) in User Satisfaction and Market Presence based on user reviews,[8] the most popular NoSQL database in containers,[9] and the #1 NoSQL among Top 50 Developer Tools & Services. from Wikipedia

@TimJuly 这里的使用场景有点类似于 Counter Cache,Redis 在这种小,并且写频繁的场景中使用还是挺合适的。至于说 大量丢数据 倒是没听说过呢,Redis 在我看来还是很成熟的产品,查阅:11 Common Web Use Cases Solved In Redis

7年前 评论

@monkey http://stackoverflow.com/questions/5400163...

我个人认为 redis 更时候做缓存,存储数据更适合用 mongodb。当然,用哪个,还是你说得算。:smile:

7年前 评论

请教一个Cache的问题,Cache::put , Cache::forever似乎都是以String类型存到Redis对吧,如果我想存hash或者集合,Cache这个Facade好像不支持,只能用PRedis这样的组件去操作。我想的对不对

7年前 评论

···
// 这个 Redis 用于数据库更新,数据库每同步一次则清空一次该 Redis 。
$update_key = 'actived_time_for_update';
$update_data = Cache::get($update_key);
$update_data[$this->id] = $now;
Cache::forever($update_key, $update_data);
···
这个有点效率低,每次取出整个数组追加上去再保存整个数组,应该用一个 hash 结构,直接追加就可以了。

7年前 评论

如果redis出现问题会抛出什么异常,当redis出现异常时怎样才能避开redis,直接取调用数据库?

7年前 评论
TimJuly

@monkey 这不是Redis的问题,是你的算法有问题.

例如有两个用户同时进入
用户A获取缓存中的数据

[
    'olduid1':'2016-01-01 00:00:00',
    'olduid2':'2016-01-01 00:00:00',
]

用户B获取缓存中的数据

[
    'olduid1':'2016-01-01 00:00:00',
    'olduid2':'2016-01-01 00:00:00',
]

用户A更新缓存中的数据

[
    'olduid1':'2016-01-01 00:00:00',
    'olduid2':'2016-01-01 00:00:00',
    'userA':'now',
]

用户B更新缓存中的数据

[
    'olduid1':'2016-01-01 00:00:00',
    'olduid2':'2016-01-01 00:00:00',
    'userB':'now',
]

你有没有发现前面用户A的数据丢失了,同理有10个用户同时访问,前面9个用户的就都丢了.

7年前 评论

如果用户多了,每次要取出来所有的用户活动信息,然后存储,这样太多了,并且有竞态条件的问题。

我觉得如果在 redis 存储的时候键名设置为 $key = "{$show_key}.{$user->id}"; 直接对单个键值操作更方便,这样不用每次取出所有再存取所有。如果需要同步的话,遍历一遍就行了,每天执行一次的计划任务,存储的时候判断这个活动时间是否是当天的,如果是就更新数据,不是的话就跳过,这样避免每条数据都操作一遍.

7年前 评论

这个思路还是不错,至于具体实现自己用自己觉得更好的办法吧

7年前 评论
monkey

@纸牌屋弗兰克 你的建议非常好哦 :+1: :+1:

@zhuzhichao @TimJuly ,竞态条件的问题我有考虑到,但是这个功能比较急迫,平衡了开发时间和现在论坛的访问量,才采取了这个方案。

接下来会我优化这块逻辑,多谢大家的宝贵意见 :smile:

7年前 评论
baitongda

缓存机制

7年前 评论

一个有序集合搞定,搞得这么麻烦。有序集合存储,排序都方便的多

4年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!