NestJs: 定时任务+redis实现阅读量功能

2,543 阅读8分钟

抛个砖头

不知道大家用了这么久的掘金,有没有对它文章中的阅读量的实现有过好奇呢?

image.png

想象一下,你每次打开一篇文章时,都会有一个数字告诉你多少人已经读过这篇文章。那么这个数字是怎么得出来的呢?

有些人可能会认为,每次有人打开文章,数字就加1,不是很简单吗? 起初我也以为这样,哈哈(和大家站在同一个高度),但(好了,我不说了,继续往下看吧!

引个玉

文章阅读量的统计看似简单,实则蕴含着巧妙的逻辑和高效的技术实现。我们想要得到的阅读量,并非简单的页面刷新次数,而是真正独立阅读过文章的人数。因此,传统的每次页面刷新加1的方法显然不够准确,它忽略了用户的重复访问。

同时,阅读作为一个高频操作,如果每次都直接写入数据库,无疑会给数据库带来巨大的压力,甚至可能影响到整个系统的性能和稳定性。这就需要我们寻找一种既能准确统计阅读量,又能减轻数据库压力的方法。

Redis,这个高性能的内存数据库,为我们提供了解决方案。我们可以利用Redis的键值对存储特性,将用户ID和文章ID的组合作为键,设置一个短暂的过期时间,比如15分钟。当用户首次访问文章时,我们在Redis中为这个键设置一个值,表示该用户已经阅读过这篇文章。如果用户在15分钟内再次访问,我们可以直接判断该键是否存在,如果存在,则不再增加阅读量,否则进行增加。

这种方法的优点在于,它能够准确地统计出真正阅读过文章的人数,而不是简单的页面刷新次数。同时,通过将阅读量先存储在Redis中,我们避免了频繁地写入数据库,从而大大减轻了数据库的压力。

最后,我们还需要考虑如何将Redis中的阅读量最终写入数据库。由于数据库的写入操作相对较重,我们不宜频繁进行。因此,我们可以选择在业务低峰期,比如凌晨2到4点,使用定时任务将Redis中的阅读量批量写入数据库。这样,既保证了阅读量的准确统计,又避免了频繁的数据库写入操作,实现了高效的系统运行。

思路梳理

  1. 😎Redis 助力阅读量统计,方法超好用!✨
  2. 🧐在 Redis 存用户和文章关系,轻松解决多次无效阅读!👏
  3. 💪定时任务来帮忙,Redis 数据写入数据库,不再 那么接下来就是实现环节

代码层面

项目使用的后端框架为NestJS

配置下redis

一、安装redis plugin

npm install --save redis

二、创建redis模块

image.png

三、初始化连接redis相关配置

@Module({
  providers: [
    RedisService,
    {
      provide: 'REDIS_CLIENT',
      async useFactory(configService: ConfigService) {
        console.log(configService.get('redis_server_host'));
        const client = createClient({
          socket: {
            host: configService.get('redis_server_host'),
            port: configService.get('redis_server_port'),
          },
          database: configService.get('redis_server_db'),
        });
        await client.connect();
        return client;
      },
      inject: [ConfigService],
    },
  ],
  exports: [RedisService],
})

Redis是一个Key-Value型数据库,可以用作数据库,所有的数据以Key-Value的形式存在服务器的内存中,其中Value可以是多种数据结构,如字符串(String)、哈希(hashes)、列表(list)、集合(sets)和有序集合(sorted sets)等类型

在这里会用到字符串和哈希两种。

创建文章表和用户表

我的项目中创建有post.entity和user.entity这两个实体表,并为post文章表添加以下

image.png 这三个字段,在这里我们只拿 阅读量 说事。

访问文章详情接口-阅读量+1

  /**
   * @description 增加阅读量
   * @param id
   * @returns
   */
  @Get('xxx/:id')
  @RequireLogin()
  async frontIncreViews(@Param('id') id: string, @Req() _req: any,) {
    console.log('frontFindOne');
    return await this.postService.frontIncreViews(+id, _req?.user);
  }

前文已经说过,同一个用户多次刷新,如果不做处理,就会产生多次无效的阅读量。 因此,为了避免这种情况方式,我们需要为其增加一个用户文章id组合而成的标记,并设置在有效时间内不产生多次阅读量。

那么,有的掘友可能会产生一个疑问,如果用户未登录,那么以游客的身份去访问文章就不产生阅读记录了吗?

其实同理!

在我的项目中只是,要求需要用户登录后才能访问, 那么我这就会以 userID_postID_ 来组成标识区分用户和文章罢了。 而如果想以游客身份,我们可以获取用户 IP_postID 这样的组合来做标识即可

接下来说下postService中调用的frontIncreViews方法 直接贴代码:

 const res = await this.redisService.hashGet(`post_${id}`);

    if (res.viewCount === undefined) {
      const post = await this.postRepository.findOne({ where: { id } });

      post.viewCount++;

      await this.postRepository.update(id, { viewCount: post.viewCount });

      await this.redisService.hashSet(`post_${id}`, {
        viewCount: post.viewCount,
        likeCount: post.likeCount,
        collectCount: post.collectCount,
      });
      // 在用户访问文章的时候在 redis 存一个 10 分钟过期的标记,有这个标记的时候阅读量不增加
      await this.redisService.set(`user_${user.id}_post_${id}`, 1, 10);

      return post.viewCount;
    } else {
      const flag = await this.redisService.get(`user_${user.id}_post_${id}`);
      console.log(flag);
      if (flag) {
        return res.viewCount;
      }

      await this.redisService.hashSet(`post_${id}`, {
        ...res,
        viewCount: +res.viewCount + 1,
      });

      await this.redisService.set(`user_${user.id}_post_${id}`, 1, 10);
      return +res.viewCount + 1;
    }
  }
  1. 从Redis获取文章阅读量:
const res = await this.redisService.hashGet(`post_${id}`);

使用Redis的哈希表结构,从键post_${id}中获取文章的信息,其中可能包含阅读量(viewCount)、点赞数(likeCount)和收藏数(collectCount)。
2. 检查Redis中是否存在阅读量:

if (res.viewCount === undefined) {

如果Redis中没有阅读量数据,说明这篇文章的阅读量还没有被初始化。
3. 从数据库中获取文章并增加阅读量:

const post = await this.postRepository.findOne({ where: { id } });
post.viewCount++;
await this.postRepository.update(id, { viewCount: post.viewCount });

从数据库中获取文章,然后增加阅读量,并更新数据库中的文章阅读量。
4. 将更新后的文章信息存回Redis:

await this.redisService.hashSet(`post_${id}`, {
  viewCount: post.viewCount,
  likeCount: post.likeCount,
  collectCount: post.collectCount,
});

将更新后的文章信息(包括新的阅读量、点赞数和收藏数)存回Redis的哈希表中。
5. 设置用户访问标记:

await this.redisService.set(`user_${user.id}_post_${id}`, 1, 10);

在用户访问文章时,在Redis中设置一个带有10分钟过期时间的标记,用于防止在10分钟内重复增加阅读量。
6. 返回阅读量:

return post.viewCount;

返回更新后的阅读量。
7. 如果Redis中存在阅读量:

} else {
  const flag = await this.redisService.get(`user_${user.id}_post_${id}`);
  console.log(flag);

如果Redis中存在阅读量数据,则检查用户是否已经访问过该文章。
8. 检查用户访问标记:

if (flag) {
  return res.viewCount;
}

如果用户已经访问过该文章(标记存在),则直接返回当前阅读量,不增加。
9. 如果用户未访问过文章:

await this.redisService.hashSet(`post_${id}`, {
  ...res,
  viewCount: +res.viewCount + 1,
});
await this.redisService.set(`user_${user.id}_post_${id}`, 1, 10);
return +res.viewCount + 1;

如果用户未访问过该文章,则增加阅读量,并重新设置用户访问标记。然后返回更新后的阅读量。

简而言之,目的是在用户访问文章时,确保文章阅读量只增加一次,即使用户在短时间内多次访问。

NestJS使用定时任务包,实现redis数据同步到数据库中

有的掘友可能疑问,既然已经用redis来做阅读量记录了,为什么还要同步到数据库中,前文开始的时候,就已经提到过了,一旦我们的项目重启, redis 数据就没了,而数据库却有着“数据持久性的优良品质”。不像redis重启后,又是个新生儿。但是它们的互补,又是1+1大于2的那种。

好了,不废话了

一、引入定时任务包 @nestjs/schedule

npm install --save @nestjs/schedule

app.module.ts 引入

image.png

二、创建定时任务模块和服务

nest g module task 
nest g service task

image.png

你可以在同一个服务里面声明多个定时任务方法。在 NestJS 中,使用 @nestjs/schedule 库时,你只需要在服务类中为每个定时任务方法添加 @Cron() 装饰器,并指定相应的 cron 表达式。以下是一个示例,展示了如何在同一个服务中声明两个定时任务:

import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';

@Injectable()
export class TasksService {
  // 第一个定时任务,每5秒执行一次
  @Cron(CronExpression.EVERY_5_SECONDS)
  handleEvery5Seconds() {
    console.log('Every 5 seconds task executed');
  }

  // 第二个定时任务,每10秒执行一次
  @Cron(CronExpression.EVERY_10_SECONDS)
  handleEvery10Seconds() {
    console.log('Every 10 seconds task executed');
  }
}

三、实现定时任务中同步文章阅读量的任务

更新文章的阅读数据

await this.postService.flushRedisToDB();

  // 查询出 key 对应的值,更新到数据库。 做定时任务的时候加上
  async flushRedisToDB() {
    const keys = await this.redisService.keys(`post_*`);
    console.log(keys);
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i];

      const res = await this.redisService.hashGet(key);

      const [, id] = key.split('_');

      await this.postRepository.update(
        {
          id: +id,
        },
        {
          viewCount: +res.viewCount,
        },
      );
    }
  }
  1. 从 Redis 获取键

    const keys = await this.redisService.keys(post_*);: 使用 Redis 服务的 keys 方法查询所有以 post_ 开头的键,并将这些键存储在 keys 数组中。 console.log(keys);: 打印出所有查询到的键。

  2. 遍历 Redis 键

    使用 for 循环遍历所有查询到的键。

  3. 从 Redis 获取哈希值

    const res = await this.redisService.hashGet(key);: 对于每一个键,使用 Redis 服务的 hashGet 方法获取其对应的哈希值,并将结果存储在 res 中。

  4. 解析键以获取 ID

    const [, id] = key.split('_');: 将键字符串按照 _ 分割,并取出第二个元素(索引为 1)作为 id。这假设键的格式是 post_<id>

  5. 更新数据库

    使用 postRepository.update 方法更新数据库中的记录。 { id: +id, }: 指定要更新的记录的 id+id 是将 id 字符串转换为数字。 { viewCount: +res.viewCount, }: 指定要更新的字段及其值。这里将 viewCount 字段更新为 Redis 中存储的值,并使用 +res.viewCount 将字符串转换为数字。

等到第二天,哈,数据就同步来了

访问:

gh_db79ec2f6f73_860.jpg

而产生的后台数据:

image.png

抛出问题

如果能看到这里的掘友,若能接下这个问题,说明你已经掌握了吖

问题1:

如何实现一个批量返回redis键值对的方法(这个方法问题2需要用到)

问题2:

用户查询文章列表的时候,如何整理数据后返回文章阅读量呈现给用户查看