一、业务场景
口算小程序,用户完成口算并获得满分,根据耗时长短进行rank排名,耗时越短,排名越高。主要有以下功能:
1. 用户数据Mysql与Redis同步:使用一个redis hash用来保存用户基本信息,field为userId,value为用户基础数据(本案例为昵称);用户修改昵称时,同步更新hash中对应userId的nickname;
2. 保存记录:口算完成后,根据当前口算结果(得分、耗时)更新对应难度的排行榜数据。用户获得满分时,并且耗时大于三秒(可以添加更专业的防脚本措施),则将userId作为field,耗时作为score,添加进排行榜zset中,并且剔除多余排名(本场景zset最大支持50名),这几步使用lua脚本保证原子性;
3. 查看排行榜:按照耗时升序排序,范围查找前20名用户,查询上述用户hash,将userId转化为nickname返回给前端。
二、数据库、数据结构
对重点涉及的Mysql表以及Redis数据结果进行说明
Mysql:
用户基础数据表
drop table if exists arithmetic_user;
create table arithmetic_user (
id bigint primary key auto_increment,
username varchar(255) comment '用户名称',
password varchar(255) comment '密码',
nickname varchar(40) default '匿名用户' comment '昵称',
integral float default 0 comment '积分',
total_integral float default 0 comment '总积分',
create_time datetime default now() comment '创建时间',
deleted tinyint default 0
);
Redis:
排行榜有序集合zset,field存放用户Id,score存放答题总耗时,根据耗时升序排序;
public static final String ARITHMETIC_RANK = "arithmetic:rank";
// 使用difficuty参数,可以对不同难度模式分别进行排行数据统计
public static String getArithmeticRank(int difficulty) {
return ARITHMETIC_RANK + ":" + difficulty;
}
用户基础数据hash,field存放用户Id,value存放nickname昵称。
三、代码示例
为方便理解,非核心代码将进行黑盒处理
1、Mysql与Redis数据同步
首次同步:项目启动时,将数据库中的用户数据同步到Redis hash中,供后续查看排行榜使用(ps:用户量大时,可以分批次进行同步操作):
@PostConstruct
public void initUserInfoCache() {
List<ToolArithmeticUserEntity> list = this.list();
String key = RedisKeys.ARITHMETIC_USER_INFO;
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
redisTemplate.delete(key);
}
// 批量添加hmset
redisTemplate.opsForHash().putAll(
key,
list.stream().collect(
Collectors.toMap(
item -> String.valueOf(item.getId()),
ToolArithmeticUserEntity::getNickname
)
)
);
}
新增用户:用户首次进入时,将数据保存至数据库中,并同步保存至Redis
@Transactional
@Override
public ArithmeticUserEntity getUserInfo() {
// 查询用户
ArithmeticUserEntity one = getUserInfo();
// 如果没查询到,则基于ip新增当前用户至数据库中
if (one == null) {
ToolArithmeticUserEntity entity = new ToolArithmeticUserEntity();
// 设置默认用户信息
entity.setInfo();
boolean b = this.save(entity);
// 用户数据同步添加至Redis中
if (b) {
String userId = String.valueOf(entity.getId());
redisTemplate.opsForHash().put(RedisKeys.ARITHMETIC_USER_INFO, userId, entity.getNickname());
}
return entity;
}
return this.getById(one.getUserId());
}
编辑昵称:当用户修改昵称时,同样需要同步修改Redis hash中的nickname(是否会存在数据不一致问题?)
@Override
public void editNickName(String nickname) {
LambdaQueryWrapper<ArithmeticUserEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ArithmeticUserEntity::getNickname, nickname);
if (this.count(queryWrapper) > 0) {
throw new GlobalException(10000, "该昵称已被使用");
}
ToolArithmeticUserEntity byId = this.getUserInfo();
byId.setNickname(nickname);
boolean b = this.updateById(byId);
if (b) {
String userId = String.valueOf(byId.getId());
redisTemplate.opsForHash().put(RedisKeys.ARITHMETIC_USER_INFO, userId, nickname);
}
}
2、保存记录,更新排行榜
@Override
public void saveRecord(ArithmeticRecordDTO entity) {
// 获取用户信息
// 保存当前答题记录至记录表
// 更新积分等
// 只有满分才参与排行,并且耗时大于3秒(或更加详细的防刷机制)
if (Objects.equals(entity.getRightCount(), entity.getQuestionCount()) && entity.getTime() > 3) {
int difficulty = entity.getDifficulty();
String key = RedisKeys.getArithmeticRank(difficulty);
float time = entity.getTime();
// 当耗时比原有的score小时,才更新。使用lua脚本封装成原子操作
String script = getLuaScript();
int total = 50;
redisTemplate.execute((RedisCallback<Object>) connection ->
connection.eval(script.getBytes(), ReturnType.INTEGER,
1, key.getBytes(), userId.toString().getBytes(),
String.valueOf(time).getBytes(), String.valueOf(total).getBytes()));
}
}
// lua语句
private String getLuaScript() {
return "local key = KEYS[1]\n" +
"local userId = ARGV[1]\n" +
"local time = tonumber(ARGV[2])\n" +
"local total = tonumber(ARGV[3])\n" +
"local score = redis.call('ZSCORE', key, userId)\n" +
"if not score then\n" +
" redis.call('ZADD', key, time, userId)\n" +
"elseif tonumber(score) > time then\n" +
" redis.call('ZADD', key, time, userId)\n" +
"end\n" +
"local size = redis.call('ZCARD', key)\n" +
"if size ~= false and size > total then\n" +
" redis.call('ZREMRANGEBYRANK', key, total, size)\n" +
"end\n" +
"return 0";
}
3、查询排行榜
@Override
public List<ArithmeticRankVO> getRankings(int difficulty) {
return getRankings(difficulty, 0, 20);
}
/**
* @param difficulty 题目难度
* @param start 最高名次(根据耗时升序排名)
* @param end 最低名次
* @return field、score
*/
private List<ArithmeticRankVO> getRankings(int difficulty, int start, int end) {
String key = RedisKeys.getArithmeticRank(difficulty);
if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
Set<ZSetOperations.TypedTuple<Object>> typedTuples = redisTemplate.opsForZSet().rangeWithScores(key, start, end);
if (typedTuples != null) {
// 先从zset中查询出userId
List<ArithmeticRankVO> list = typedTuples.stream().map(tuple -> {
ArithmeticRankVO vo = new ArithmeticRankVO();
vo.setUserId(Objects.requireNonNull(tuple.getValue()).toString());
vo.setTime(Objects.requireNonNull(tuple.getScore()).floatValue());
return vo;
}).collect(Collectors.toList());
// 获取nickname
String infoKey = RedisKeys.ARITHMETIC_USER_INFO;
// 再使用userId去hash中查出nickname
List<Object> nicknameList = redisTemplate.opsForHash().multiGet(infoKey, list.stream().map(ArithmeticRankVO::getUserId).
for (int i = 0; i < list.size() && i < nicknameList.size(); i++) {
list.get(i).setNickname(nicknameList.get(i) == null ? "账号昵称异常" : nicknameList.get(i).toString());
}
return list;
}
}
return new ArrayList<>();
}
四、效果
演示地址(菜鸡小站,大佬手下留情):
标签:实战,String,userId,Redis,用户,entity,排行榜,key,nickname From: https://blog.csdn.net/m0_62467665/article/details/141132023