eggjs+redis实现活动排行榜

一、业务场景

活动页面里左边活动规则,右边一个排行榜,底下有一个分享按钮,前30名有奖品,排行榜一共展示前50名用户信息,点击分享按钮会复制一个链接,分享该链接给其他人时,新用户点击链接会在邀请数量加1

二、思维导图

高并发场景处理中,需要把任务拆分提前或延后执行。

下面链路2有可能出现多人同时点击邀请链接,有高并发风险,这里做了优化处理。就是把链接1拆出来了,提前在库里生成用户数据,然后全程用redis响应处理,最后再用定时任务延迟更新数据库

三、表设计

invite_user_ids 为该用户邀请的新用户id集合,可用于邀请用户去重,也记录邀请过哪些新用户

三、具体实现

1,首先思维导图里的链路1,用户点击复制链接时,调用创建接口,在活动表xx_active创建一个分享用户信息。

javascript 复制代码
// 初始化用户
async initActivityUser() {
  // 根据token获取当前用户信息
  const userInfo =  ctx.helper.getTokenInfo()
  const model = ctx.model.xxActivity;
  const targetItem = await model.findOne({
    where: { userId: userInfo.id }
  });
  if (targetItem) return ctx.success('用户已创建成功');
  try {
    // 不存在 直接设置值
    let userObj = {
      userId: userInfo.id,
      userName: userInfo.username || '',
      avatar: userInfo.avatar || '',
      inviteNum: 0,
      inviteNewNum: 0,
      inviteUserIds: JSON.stringify([])
    }
    await model.create(userObj)
  } catch (error) {
    console.log(error)
  }
  ctx.success('创建成功');
}

2,别人点击邀请链接时,判断是否是新用户,否则需要用到redis去重,判断用户是否已经邀请过该用户,如果邀请过,则直接返回,如果未邀请,则记录用户id到redis,并用户数量+1,这里如果发在群里给用户点击,有可能会出现高并发场景,所以最后是用定时任务把增量数据更新到数据库。

javascript 复制代码
// 邀请用户,计数+1
async inviteUser() {
  const { ctx, app } = this;
  const reqBody = ctx.request.body;
  const rule = {
    inviteId: { type: 'string', required: false }, // 邀请id
  };
  ctx.valid(rule, reqBody);
  const { inviteId } = reqBody
  // 根据token获取当前用户信息
  const userInfo =  ctx.helper.getTokenInfo()
  if (userInfo.id === inviteId) {
    ctx.success({
      msg: '不能自己邀请自己',
      data: null
    });
    return
  }
  // 判断活动是否结束
  const end_time = new Date('2024-01-30 23:59:59').getTime()
  const new_time = new Date().getTime()
  if (new_time > end_time) {
    ctx.success({
      msg: '活动已结束',
      data: null
    });
    return
  }

  // 查询redis value是不是存在
  let scoreVal = null
  let msg =''
  let backData = '';
  try {
    // 获取redis的有序集合中的邀请数量值
    scoreVal = await app.redis.zscore('RANK-xx', inviteId);
  } catch (error) {
    console.log(error)
  }

  // 判断是否是新用户, 没判断规则不一样,这里就不写了
  // 可以查询用户表,判断当前用户的创建时间是否是1分钟内创建的,如果是,则认为是新用户,我这里在打开邀请链接时,会触发先创建用户id,再进来该接口。
  // 也可以用redis在创建用户时记录下该用户id,1分钟失效,这里直接取redis的用户信息判断,取到则是新用户,取不到则不用户

  // redis判断去重
  try {
    // SADD将新成员添加到集合中,如果这个元素不存在,就返回1,如果存在这个元素就返回0
    const hasInvited = await app.redis.sadd(`RANK-xx-USER:${inviteId}`, userInfo.id)
    if (hasInvited === 1) {
      // 当相同数量时,先邀请的排在前面,这里通过用最大时间,减去当前时间,就会获取较大数的排名。
      scoreVal = Number(Number(scoreVal || 0).toFixed()) + 1
      const rank_val = scoreVal + (max_time - parseInt(Date.now() / 1000)) / 1000000000000;
      await app.redis.sadd(`RANK-xx-UPDATE-USER`, inviteId) // 添加需要更新数据的用户标识,用于定时任务更新数据库
      await app.redis.zadd('RANK-xx', rank_val, inviteId); // 向有序集合 更新数据
      backData = rank_val
       msg = '邀请成功'
      // await updateSql(this, inviteId, backData) // 这里不做更新数据库操作,在定时任务执行
    } else {
      msg = '不能重复邀请'
    }
  } catch (error) {
    console.log("🚀 ~ inviteUser ~ error:", error)
  }
  ctx.success({
    msg,
    data: Number(backData || scoreVal || 0).toFixed()
  });
}

egg的schedule定时任务里把redis数据更新到数据库

javascript 复制代码
module.exports = {
  schedule: {
    cron: '0 */5 * * * *', // 5分钟执行一次 更新数据库
    type: 'worker',
    disable: false // 活动时间是否已结束
  },
  async task(ctx) {
    const app = ctx.app;
    try {
      const model = ctx.model.xxActivity;
      const userIds = await app.redis.sunion(`RANK-xx-UPDATE-USER`); // 获取需要更新ids的集
      console.log('执行定时任务,更新活动数据', userIds)
      for (let i = 0; i < userIds.length; i++) {
        const inviteId = userIds[i];
        const inviteUserIds = await app.redis.sunion(`RANK-xx-USER:${inviteId}`); // 获取邀请ids的集合
        const backData = await app.redis.zscore('RANK-xx', inviteId); // 获取分数
        const targetItem = await model.findOne({
          where: { userId: inviteId }
        });
        if (!targetItem) {
          continue;
        }

        await targetItem.update({
          inviteNum: backData,
          inviteUserIds: JSON.stringify(inviteUserIds)
        });
        await app.redis.srem(`RANK-xx-UPDATE-USER`, inviteId) // 清空该用户标识,用于定时任务更新数据库
      }
    } catch (err) {
      console.log("🚀 ~ file task ~ err:", err)
    }
  }
};

3,获取排行榜数据

ini 复制代码
async getRankList() {
    const { ctx, app } = this;
    const query = ctx.query;
    const model = ctx.model.xxActivity;
    const rule = {
      limit: { type: 'string', required: false } // 返回数量
    };
    ctx.valid(rule, query);
    const limit = query.limit || 49;
    // 根据token获取当前用户信息
    const userInfo =  ctx.helper.getTokenInfo()
    let rank = 1;
    let promiseAry = []
    let scoreObj = {} // 分数对象
   
    let userRank = 1;
    let userScore = 0;
    // 获取redis 排行数据
    let redisList = []
    try {
      // zrevrange 按照最高成绩排名(即从大到小排序)
      // zrange  按照最低成绩排名
      // 获取redis的有序集合数据,会自动排序
      redisList = await app.redis.zrevrange('RANK-xx', 0, limit, 'WITHSCORES') // Withscores 是把score也打印出来
      userRank = await app.redis.zrevrank('RANK-xx', userId) // zrevrange 获取该用户的排名
      userRank = Number(userRank) + 1
      const score = await app.redis.zscore("RANK-xx", userId) // 获取用户当前的分数
      userScore = Number(score).toFixed()
    } catch (error) {
      console.log(error)
    }

    let foucusUser = {}
    let results = []

    if (redisList.length > 0) {
      console.log('进来读取redis的数据')
      // 遍历请求排名前50的用户,获取这些用户信息,例如头像
      for(let index = 0; index < redisList.length; index +=2) {
        console.log(`第${rank}名是:${redisList[index]},分数:${Number(redisList[index + 1]).toFixed()}`)
        scoreObj[redisList[index]] = Number(redisList[index + 1]).toFixed()
        rank++
        const findParams = {
          raw: true,
          where: { userId: redisList[index] },
          attributes: {
            exclude: ['deleted_at', 'inviteUserIds']
          }
        }
        const pItem = model.findOne(findParams)
        promiseAry.push(pItem)
      }
      results = await Promise.all(promiseAry)
      results.forEach((rows, index) => {
        if (rows) {
          rows.rank = index + 1
          rows.inviteNum = scoreObj[rows.userId] // 取redis里的邀请数量
        }
      })
      const focuesParams = {
        raw: true,
        where: { userId: userInfo.id },
        attributes: {
          exclude: ['deleted_at', 'inviteUserIds']
        }
      }
      // 获取当前用户的 用户信息,例如头像等
      const _focusItem = await model.findOne(focuesParams)
      foucusUser = {
        ..._focusItem,
        inviteNum: redisInfo.userScore,
        rank: redisInfo.userRank
      }
    } else {
      console.log('进来读取数据库的数据')
      const findParams = {
        raw: true,
        order: [
          ['invite_num', 'DESC'],
          ['updated_at', 'DESC'],
        ],
        attributes: {
          exclude: ['deleted_at']
        }
      }
      const allActivityList = await model.findAll(findParams)
      // 对数据库所有数据遍历,并把数据重新写入redis
      const _result = await sqlSortData(app, allActivityList, limit, userInfo.id)
      results = _result.list || []
      foucusUser = _result.foucusUser; // 当前用户的信息
    }
    ctx.success({
      list: results,
      userInfo: foucusUser
    });
  }

四、活动总结

1,邀请活动看似简单,但活动的边界和规则一定要理清和确定好

2,一些特殊性的规则需要做好容错处理,不能影响正常数据

第一次使用redis实现活动排行榜,有什么问题欢迎一起交流

相关推荐
昨天今天明天好多天1 小时前
【Node.js]
前端·node.js
熊的猫2 小时前
DOM 规范 — MutationObserver 接口
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
爱编程的鱼5 小时前
Node.js事件循环:解锁异步编程的奥秘
node.js
南暮思鸢5 小时前
Node.js is Web Scale
经验分享·web安全·网络安全·node.js·ctf题目·hackergame 2024
程序员小杰@5 小时前
Playwright 快速入门:Playwright 是一个用于浏览器自动化测试的 Node.js 库
node.js
Martin -Tang6 小时前
vite和webpack的区别
前端·webpack·node.js·vite
王解7 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
ldq_sd17 小时前
node.js安装和配置教程
node.js
我真的很困17 小时前
坤坤带你学浏览器缓存
前端·http·node.js
whyfail21 小时前
ESM 与 CommonJS:JavaScript 模块化的两大主流方式
javascript·node.js