如何在 Nest 中实现实时排行榜功能

1,633 阅读5分钟

前情提要

为了鼓励大家学习英语的积极性(划掉),脑子一热做了个排行榜鼓励大家内卷(并不)。

设计是当用户完成一课时的时候,给用户完成 count++, 把卷王都放在排行榜里让大家谴责。

先叠个 buff,以下内容仅仅代表个人观点,我还是个菜鸡,第一次写文章,大佬们轻喷

为什么使用 redis 而不是 mysql

  1. 性能与效率: Redis是一个基于内存的数据存储系统,读写速度比 mysql 快很多,对于需要频繁更新和访问的排行榜数据来说非常重要。
  2. 数据结构优势:Redis 的 ZSET(有序集合)是一个非常适合排行榜的数据结构。它可以保持元素的排序,每个元素都关联一个分数(score),这使得更新排名和检索排名列表变得非常高效。在 MySQL 中实现类似的功能,可能需要复杂的查询和额外的逻辑来处理排名。
  3. 扩展性:Redis 具有良好的水平扩展性。随着用户数量的增长和访问量的增加,Redis 能够更容易地扩展以处理更大的负载。而 MySQL 在处理大量并发读写操作时可能会面临性能瓶颈。
  4. 简化复杂性:使用 Redis 可以简化应用程序的复杂性,特别是在需要处理实时排行榜的场景中。Redis 提供的操作通常比 SQL 查询更直观简单,对于开发者来说更易于理解和实现。
  5. 成本效益:虽然 Redis 需要占用更多的内存资源,但考虑到它提供的性能优势,这通常是一个值得的投资。相比之下,优化 MySQL 以处理高性能的排行榜可能需要更多的开发和维护成本。

Nest 如何连接 Redis

安装第三方库

pnpm add @nestjs-modules/ioredis ioredis

如何使用

  1. app.module.ts 添加以下代码
import { Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { AppController } from './app.controller';

@Module({
  imports: [
    RedisModule.forRoot({
      type: 'single',
      url: 'redis://localhost:6379',
    }),
  ],
  controllers: [AppController],
})
export class AppModule {}

2.在需要用到的 service 里使用 @InjectRedis 注入

import Redis from 'ioredis';
import { Controller, Get } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';

@Controller()
export class AppController {
  constructor(
    @InjectRedis() private readonly redis: Redis,
  ) {}

  @Get()
  async getHello() {
    await this.redis.set('key', 'Redis data!');
    const redisData = await this.redis.get("key");
    return { redisData };
  }
}   

就可以快乐使用 redis 了

下面我们需要简单的来了解以下 ZSET 便于理解后续的代码

ZSET

commands

  • ZADD: 为ZSET添加一个或多个给定分数的成员 或者说用一个或多个成员初始化ZSET

  • ZREM: 从 ZSET 中删除一个已存在成员

  • ZRANGE: 按排序顺序从 ZSET 中获取所有成员

  • ZRANGEBYSCORE: 根据分数范围提取 ZSET 中的成员

  • ZCOUNT: 以 ZSET 为单位,返回分数在提供范围内的成员数量

  • ZRANK:根据成员在 ZSET 中的得分,返回改成员的位置

  • ZSCORE:返回 ZSET 中成员的分数

  • ZINCRBY: 返回ZSET 中某个成员的分数

examples

ZADD

为 ZSET 指定 key 添加 items

ZADD history 70 xiaoming 80 xiaoli 90 xiaobai

## output
127.0.0.1:6379> ZADD history 70 xiaoming 80 xiaoli 90 xiaobai
(integer) 3

ZRANGEBYSCORE

从 ZSET 指定 key 中获取范围内的分数

ZRANGEBYSCORE history 0 100

## output
127.0.0.1:6379> ZRANGEBYSCORE history 0 100
1) "xiaoming"
2) "xiaoli"
3) "xiaobai"

ZRANGE

从 ZSET 指定 key 中获取范围内的items 升序

ZRANGE history 0 10 WITHSCORES

## output
127.0.0.1:6379> ZRANGE history 0 10 WITHSCORES
1) 70 "xiaoming"
2) 80 "xiaoli"
3) 90 "xiaobai"

ZREVRANGE

ZRANGE的倒序版本

ZREVRANGE history 0 10 WITHSCORES

## output
127.0.0.1:6379> ZREVRANGE history 0 10 WITHSCORES
1) "xiaobai"
2) "90"
3) "xiaoli"
4) "80"
5) "xiaoming"
6) "70"

ZSCORE

从 ZSET 指定 key 中获取 item 分数

ZSCORE history xiaoli

## output
127.0.0.1:6379> ZSCORE history xiaoli
"80"

ZCOUNT

从 ZSET 指定 key 中获取范围内的人数

## 获取所有
ZCOUNT history -inf +inf
## output
127.0.0.1:6379> ZCOUNT history -inf +inf
(integer) 3
## 获取指定
ZCOUNT history 0 80
## output
127.0.0.1:6379> ZCOUNT history 0 80
(integer) 2

ZRANK

从 ZSET 指定 key 中获取item 分数排名

## output
127.0.0.1:6379> ZRANK history xiaoli
(integer) 1
127.0.0.1:6379> ZRANK history xiaoming
(integer) 0

这里可以看出来 排名是从0开始的

ZINCBY

从 ZSET 指定 key 中添加 item 的分数

ZINCRBY history 5 xiaoming

## output
127.0.0.1:6379> ZINCRBY history 5 xiaoming
"75"

ZREM

从 ZSET 指定 key 中 删除一个 item

ZREM history xiaoli

## output
127.0.0.1:6379> ZREM history xiaoli
(integer) 1
127.0.0.1:6379> ZCOUNT history -inf +inf
(integer) 2

下面我们来讲讲如何实现

如何实现

先看整体的 service 文件

import { InjectRedis } from '@nestjs-modules/ioredis';
import { Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { UserEntity } from '../user/user.decorators';

@Injectable()
export class RankService {
  private readonly FINISH_COUNT_KEY = `user:finishCount`;

  constructor(@InjectRedis() private readonly redis: Redis) {}

  async userFinishCourse(userId: number, username: string) {
    const member = `${userId}-${username}`;
    let count = await this.redis.zscore(this.FINISH_COUNT_KEY, member);
    if (!count) {
      await this.redis.zadd(this.FINISH_COUNT_KEY, 1, member);
    } else {
      await this.redis.zincrby(this.FINISH_COUNT_KEY, 1, member);
    }
    count = await this.redis.zscore(this.FINISH_COUNT_KEY, member);
    return count;
  }

  private getUserName(member: string) {
    return member.split('-')[1];
  }

  private translateList(rankList: string[]) {
    let res = [];
    for (let i = 0; i < rankList.length; i += 2) {
      let username = this.getUserName(rankList[i]);
      let count = rankList[i + 1];
      res.push({ username, count });
    }
    return res;
  }

  // return top 10 and self rank
  async getRankList(user: UserEntity) {
    // return [member, count, member, count, ...]
    let rankList = await this.redis.zrevrange(
      this.FINISH_COUNT_KEY,
      0,
      9,
      'WITHSCORES',
    );
    let self = null;
    if (user) {
      const userRank = await this.redis.zrevrank(
        this.FINISH_COUNT_KEY,
        `${user.userId}-${user.username}`,
      );
      const userCount =
        (await this.redis.zscore(
          this.FINISH_COUNT_KEY,
          `${user.userId}-${user.username}`,
        )) ?? 0;
      self = { username: user.username, count: userCount, rank: userRank };
    }
    return {
      list: this.translateList(rankList),
      self,
    };
  }
}

关键方法就是 userFinishCoursegetRankList

userFinishCourse

在这个方法中,我们把${userId}-${username}当做用户的标识(不考虑更改用户名的情况下),先去 redis 中查找这个标识有没有这条数据,如果没有就记录,有的话则 count++

getRankList

这里因为我们需要卷王在最上面 所以得使用 ZREVRANK 来获取降序的数据, 可以看出ZSET 也是从下标0 开始的 这里我们需要更多或者更少数据调整9 就好了,剩下的就是对一些数据的处理,屏幕前的各位大佬看一眼就懂了

实现效果

CleanShot 2024-01-29 at 15.13.32@2x.png

最后

感谢崔哥让我参与到开源中,让我这个菜鸡学到不少东西,感谢催学社各位大佬对小弟的指点,感谢各位看官大佬。

欢迎大家来 pr 来体验这个简单模式学英语的项目: github