前言
为什么所用 redis 作为缓存来实现点赞服务, 而不是直接就使用 mysql 来完成?
使用 Redis 的集合数据结构来存储点赞用户的 ID,方便快速判断用户是否已点赞;
当用户频繁的点赞和取消点赞时, 无需操作数据库, 减轻服务器压力
Redis 可以承受高并发的读写操作。当大量用户同时点赞时,Redis 可以先将这些点赞请求缓存起来,然后由后台线程逐步将数据持久化到 MySQL
实现
查询流程
数据存储格式
redis
# 存储用户点赞状态(SET)
SADD picture_likes:{picture_id} {user_id} # 用户点赞图片
SREM picture_likes:{picture_id} {user_id} # 取消点赞
# 存储点赞计数器(String)
INCR picture_like_count:{picture_id} # 点赞数+1
DECR picture_like_count:{picture_id} # 点赞数-1
# 存储用户点赞历史(ZSET)用于做增量同步
ZADD user_likes:{picture_id} {timestamp} {user_id} # 按时间排序
ZREM user_likes:{picture_id} {user_id} # 删除点赞关系时删除点赞历史
ADD picture_likes:last_sync_timestamp {timestamp} # 用做增量同步的时间参考
mysql
下面是用户点赞关系表, 至于 likesCount 则作为字段添加进 picture 表
create table user_likes
(
id bigint auto_increment
primary key,
user_id bigint not null,
picture_id bigint not null,
created_time timestamp default CURRENT_TIMESTAMP not null,
constraint uk_user_picture
unique (user_id, picture_id)
);
核心代码实现
点赞或取消点赞
相关参数构建
根据自己实际情况来
Long userId = userService.getLoginUser().getId();
String key = PICTURE_LIKE_PREFIX + request.getPictureId();
String userIdStr = userId.toString();
String likeCountKey = PICTURE_LIKE_COUNT_PREFIX + request.getPictureId();
String likeTimeKey = PICTURE_LIKE_TIME_PREFIX + request.getPictureId();
// 查询用户是否已经点赞
boolean isLiked = Boolean.TRUE.equals(
stringRedisTemplate.opsForSet().isMember(key, userId.toString())
);
操作 redis
为了防止指令穿插导致不一致性, 需要保证操作的原子性, 这里使用 管道 + MULTI 和 EXEC 命令来保证原子性和隔离性(也可以使用 lua 脚本来完成)
stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
connection.multi(); // 开启事务
if (!isLiked) {
// 记录用户点赞关系
connection.sAdd(key.getBytes(), userIdStr.getBytes());
// 记录用户点赞时间
connection.zAdd(likeTimeKey.getBytes(), System.currentTimeMillis() / 1000.0, userIdStr.getBytes());
// 增加点赞计数
connection.incr(likeCountKey.getBytes());
} else {
// 移除用户点赞关系
connection.sRem(key.getBytes(), userIdStr.getBytes());
// 用处用户赞时间
connection.zRem(likeTimeKey.getBytes(), userIdStr.getBytes());
// 减少点赞计数
connection.decr(likeCountKey.getBytes());
}
return connection.exec();
});
补充: redis 的事务机制
当使用事务来执行多个指令时,通过 MULTI 命令开启事务,将多个指令放入一个事务中,然后使用 EXEC 命令来原子性地执行这些指令。在 EXEC 执行期间,Redis 会按照顺序依次执行事务中的指令,不会被其他客户端的命令打断,从而保证了事务内指令执行的原子性和顺序性,也就确保了两条指令执行的间隔内没有其他指令被执行。
查询点赞数据
这里需要查询出用户是否给图片点赞, 以及图片对应的点赞总数
public List<LikeInfoVO> batchCheckLikeStatus(Long userId, List<Long> pictureIds) {
// 1. 使用Pipeline同时查询两种数据
List<Object> results = stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> {
// 查询用户点赞状态
for (Long picId : pictureIds) {
connection.sIsMember(
(PICTURE_LIKE_PREFIX + picId).getBytes(),
userId.toString().getBytes()
);
}
// 查询点赞总数
// 未匹配的项会返回空
for (Long picId : pictureIds) {
connection.get(
(PICTURE_LIKE_COUNT_PREFIX + picId).getBytes()
);
}
return null;
});
// 2. 处理混合结果
List<LikeInfoVO> resultMap = new ArrayList<>();
int halfSize = results.size() / 2;
for (int i = 0; i < halfSize; i++) {
Boolean isLiked = (Boolean) results.get(i);
Integer likeCount = results.get(i + halfSize) != null ?
Integer.parseInt((String) results.get(i + halfSize)) : 0;
resultMap.add(new LikeInfoVO(isLiked, likeCount));
}
return resultMap;
}
将点赞数据封装进响应结果
List<LikeInfoVO> likeStatus = batchCheckLikeStatus(userId, pictureIds);
List<PictureVO> vos = IntStream.range(0, records.size()).mapToObj(i -> {
PictureVO pictureVO = new PictureVO();
BeanUtils.copyProperties(records.get(i), pictureVO);
pictureVO.setLike(likeStatus.get(i).getIsLike());
pictureVO.setLikesCount(likeStatus.get(i).getLikesCount());
return pictureVO;
}).collect(Collectors.toList());
数据同步
增量同步 or 全量同步
使用定时任务来完成将 redis 中数据同步到 mysql, 这里就不使用全量同步了, 一方面全量同步性能底, 再未处理 redis 删除的点赞关系
代码
@Scheduled(fixedRate = 300000)
public void syncIncrementally() {
log.info("开始: 增量同步图片用户点赞关系到数据库, 并处理Redis已删除的点赞关系");
// 1. 获取Redis最后同步时间戳
long lastSyncTime = getLastSyncTimestamp();
// 2. 同步新增点赞
syncNewLikes(lastSyncTime);
// 3. 同步取消点赞
syncUnlikes();
// 5. 同步图片点赞数量
syncPicLikesCount();
// 4. 更新同步时间
updateSyncTimestamp();
log.info("结束: 增量同步图片用户点赞关系到数据库, 并处理Redis已删除的点赞关系");
}
设置同步时间和获取最后同步时间
// 获取最后同步时间(默认返回24小时前的时间戳)
private long getLastSyncTimestamp() {
String timestampStr = stringRedisTemplate.opsForValue().get(LAST_SYNC_KEY);
return timestampStr != null ?
Long.parseLong(timestampStr) :
System.currentTimeMillis() - 86400_000; // 默认24小时前
}
// 更新同步时间戳为当前时间
private void updateSyncTimestamp() {
stringRedisTemplate.opsForValue().set(
LAST_SYNC_KEY,
String.valueOf(System.currentTimeMillis())
);
}
同步新增点赞
private void syncNewLikes(long sinceTime) {
// 使用ZSET记录点赞时间戳
Set<String> newLikeKeys = stringRedisTemplate.keys(PICTURE_LIKE_PREFIX + "*");
Objects.requireNonNull(newLikeKeys).forEach(key -> {
long pictureId = Long.parseLong(StrUtil.removePrefix(key, PICTURE_LIKE_PREFIX));
// 获取新增点赞用户(ZRANGEBYSCORE)
Set<String> newUserIds = stringRedisTemplate.opsForZSet()
.rangeByScore(PICTURE_LIKE_TIME_PREFIX + pictureId, sinceTime, Double.MAX_VALUE);
List<UserLikes> entityList = Objects.requireNonNull(newUserIds).stream().map(userId -> {
UserLikes userLikes = new UserLikes();
userLikes.setPictureId(pictureId);
userLikes.setUserId(Long.parseLong(userId));
return userLikes;
}).collect(Collectors.toList());
// 批量插入并忽略重复的元素
if (CollUtil.isNotEmpty(entityList)) {
userLikesMapper.insertIgnoreBatch(entityList);
}
});
}
同步取消点赞
private void syncUnlikes() {
Set<String> likeKeys = stringRedisTemplate.keys(PICTURE_LIKE_PREFIX + "*");
Objects.requireNonNull(likeKeys).forEach(key -> {
Long pictureId = Long.parseLong(key.substring(PICTURE_LIKE_PREFIX.length()));
Set<String> members = stringRedisTemplate.opsForSet().members(key);
Set<Long> redisUserIds = Objects.requireNonNull(members).stream()
.map(Long::parseLong).collect(Collectors.toSet());
QueryWrapper<UserLikes> wrapper = new QueryWrapper<>();
wrapper.select("user_id")
.eq("picture_id", pictureId);
// 从Redis Set与MySQL比对找出取消的点赞
Set<Long> mysqlUserIds = userLikesMapper.selectList(wrapper).stream()
.map(UserLikes::getUserId).collect(Collectors.toSet());
// 找出MySQL有但Redis没有的记录
mysqlUserIds.removeAll(redisUserIds);
// 删除取消的记录
if (CollUtil.isNotEmpty(mysqlUserIds)) {
UpdateWrapper<UserLikes> userLikesUpdateWrapper = new UpdateWrapper<>();
userLikesUpdateWrapper.eq("picture_id", pictureId)
.in("user_id", mysqlUserIds);
userLikesMapper.delete(userLikesUpdateWrapper);
}
});
}
好的!本次分享到这就结束了
如果对铁汁你有帮助的话,记得点赞👍+收藏⭐️+关注➕
我在这先行拜谢了:)