一、业务场景
活动页面里左边活动规则,右边一个排行榜,底下有一个分享按钮,前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实现活动排行榜,有什么问题欢迎一起交流