需求描述
点赞和关注功能一般都会有排序的潜在需求 对应到redis就是有序集合zset 用时间戳表示分数
点赞为最早点赞的显示在前面 比如微信朋友圈点赞 所以直接取出即可
关注为最新的关注人显示在前面 所以要用倒序的命令reverseRange去取即可
点赞功能
sortedset存储方式
key: 前缀+blogId
value: 点赞的用户id
score: 当前时间戳
表结构设计
点赞表
CREATE TABLE `tb_user_blog_like` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`blog_id` bigint NOT NULL COMMENT '帖子id',
`user_id` bigint NOT NULL COMMENT '用户id',
`created_at` timestamp NOT NULL COMMENT '创建时间',
`updated_at` timestamp NOT NULL COMMENT '更新时间',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1点赞0未点赞',
PRIMARY KEY (`id`),
KEY `idx_user_id_blog_id` (`user_id`,`blog_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
关注表
CREATE TABLE `tb_follow` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint unsigned NOT NULL COMMENT '用户id',
`follow_user_id` bigint unsigned NOT NULL COMMENT '被关联的用户id',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1关注0未关注',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_user_id_follow_user_id` (`user_id`,`follow_user_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT
点赞接口:
@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
blogService.like(id);
return Result.ok();
}
service:
/**
* like posts
*
* @param blogId
*/
@Override
public void like(Long blogId) {
Long userId = UserHolder.getUser().getId();
DefaultRedisScript<Object> redisScript = new DefaultRedisScript<>("" +
"local blogId = ARGV[1];" +
"local userId = ARGV[2];" +
"local blogKey = KEYS[1] .. blogId;" +
"local messageQueueKey = KEYS[2];" +
"local score = redis.call('zscore', blogKey, userId);" +
"if(score == false) then " +
"local time = redis.call('time');" +
"local nowMills = time[1] * 1000 + math.floor(time[2] / 1000);" +
"redis.call('zadd', blogKey, nowMills, userId);" +
"else " +
"redis.call('zrem', blogKey, userId); " +
"end; " +
"redis.call('xadd', messageQueueKey, '*', 'userId', userId, 'blogId', blogId);" +
"");
stringRedisTemplate.execute(redisScript, Arrays.asList(BLOG_LIKED, BLOG_USER_STREAMS_LIKE), blogId.toString(), userId.toString());
}
public boolean isLike(String userId, String blogId) {
return Optional.ofNullable(stringRedisTemplate.opsForZSet().score(BLOG_LIKED + blogId, userId)).orElse(Double.valueOf(0)) > 0;
}
public int getLiked(String blogId) {
return stringRedisTemplate.opsForZSet().count(BLOG_LIKED + blogId, 0, System.currentTimeMillis()).intValue();
}
使用lua脚本保证一致性 如果有userId就删掉 没有就添加 然后发送消息到队列供消费端消费回写db
开启一个线程去轮询消费:
@PostConstruct
private void initJob() {
threadPoolExecutor.submit(new BlogLikeJob());
}
private class BlogLikeJob implements Runnable {
private boolean updateLike(Long blogId, Long userId) {
RLock rLock = redissonClient.getLock("lock:blog:update:like:" + blogId);
try {
boolean tryLock = rLock.tryLock(2L, TimeUnit.SECONDS);
if (tryLock) {
UserBlogLike userBlogLike = new UserBlogLike();
userBlogLike.setUserId(userId);
userBlogLike.setBlogId(blogId);
Integer status = userBlogLikeService.exist(userBlogLike);
int delta = Objects.nonNull(status) && status == 1 ? -1 : 1;
Boolean success = transactionTemplate.execute(transactionStatus -> {
long updated = blogMapper.updateLike(blogId, delta);
long toggleLike;
if (Objects.isNull(status)) {
toggleLike = userBlogLikeService.insert(userBlogLike);
} else {
userBlogLike.setStatus(status == 1 ? 0 : 1);
toggleLike = userBlogLikeService.update(userBlogLike);
}
if (updated < 1 || toggleLike < 1) {
if (!transactionStatus.isCompleted()) {
transactionStatus.setRollbackOnly();
}
}
return updated > 1 && toggleLike > 1;
});
return Boolean.TRUE.equals(success);
}
return false;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (rLock.isHeldByCurrentThread() && rLock.isLocked()) {
rLock.unlock();
}
}
}
@Override
public void run() {
while (true) {
try {
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(BLOG_USER_STREAMS_LIKE, ReadOffset.lastConsumed())
);
if (Objects.isNull(records) || records.isEmpty()) {
continue;
}
MapRecord<String, Object, Object> record = records.get(0);
Map<Object, Object> entries = record.getValue();
Long blogId = Long.valueOf((String) entries.get("blogId"));
Long userId = Long.valueOf(entries.get("userId").toString());
log.info("消费点赞数据: blogId: {}, userId: {}", blogId, userId);
boolean success = this.updateLike(blogId, userId);
if (success) {
stringRedisTemplate.opsForStream().acknowledge(BLOG_USER_STREAMS_LIKE, "g1", record.getId());
}
} catch (Exception e) {
log.info("handle update blog like exception: {}", e);
handlePendingList();
}
}
}
private void handlePendingList() {
int retries = 5;
while (retries-- > 0) { // loop read
try {
// there is equal to "execute the XREADGROUP GROUP g1 c1 COUNT 1 STREAMS seckill:voucher:streams.orders 0" commands
List<MapRecord<String, Object, Object>> mapRecords = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"), // specify the group and customer
StreamReadOptions.empty().count(1), // specify the number of messages
StreamOffset.create(BLOG_USER_STREAMS_LIKE, ReadOffset.from("0")) // specify the streams which the messages read from, here are read from historical records
);
if (Objects.isNull(mapRecords) || mapRecords.isEmpty()) {
// messages pending queue is empty, end the process
break;
}
MapRecord<String, Object, Object> record = mapRecords.get(0); // get a id:entries map
Map<Object, Object> entries = record.getValue();// get the entries map
Long blogId = Long.valueOf(entries.get("blogId").toString());
Long userId = Long.valueOf(entries.get("userId").toString());
log.info("消费点赞数据: blogId: {}, userId: {}", blogId, userId);
boolean success = this.updateLike(blogId, userId);
if (success) {
stringRedisTemplate.opsForStream().acknowledge(BLOG_USER_STREAMS_LIKE, "g1", record.getId()); // acknowledge the success
}
} catch (Exception e) {
log.info("handle pending-list update blog like exception: {}", e);
try {
Thread.sleep(20); // re-read in 20ms later
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
log.info("handle pending-list update blog like failure");
}
}
public boolean isLike(String userId, String blogId) {
return Optional.ofNullable(stringRedisTemplate.opsForZSet().score(BLOG_LIKED + blogId, userId)).orElse(Double.valueOf(0)) > 0;
}
public int getLiked(String blogId) {
return stringRedisTemplate.opsForZSet().count(BLOG_LIKED + blogId, 0, System.currentTimeMillis()).intValue();
}
和redis涉及写的操作都用lua保证原子性
获取点赞top5:
/**
* 查询点赞用户Top5
*
* @param id
* @return
*/
@Override
public List<UserDTO> queryLikedUsers(Long id) {
String key = BLOG_LIKED + id;
Set<String> ids = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (Objects.isNull(ids) || ids.isEmpty()) {
return Collections.emptyList();
}
return userService.findByIds(ids.stream().collect(Collectors.toList()));
}
关注操作和点赞操作一个道理 直接上代码:
关注接口:
@PutMapping("/{id}/{follow}")
public Result updateFollow(@PathVariable Long id, @PathVariable Integer follow) {
followService.updateFollow(id, follow);
return Result.ok();
}
@Override
public void updateFollow(Long followUserId, Integer status) {
DefaultRedisScript<Object> redisScript = new DefaultRedisScript<>("" +
"local userId = ARGV[1];" +
"local userFollowKey = KEYS[1] .. userId;" +
"local followMessageQueueKey = KEYS[2];" +
"local followUserId = ARGV[2];" +
"local status = ARGV[3];" +
"local score = redis.call('zscore', userFollowKey, followUserId);" +
"if(status == '1' and score == false) then " +
"local time = redis.call('time');" +
"local nowMills = time[1] * 1000 + math.floor(time[2] / 1000);" +
"redis.call('zadd', userFollowKey, nowMills, followUserId);" +
"end; " +
"if(status == '0' and score ~= false) then " +
"redis.call('zrem', userFollowKey, followUserId);" +
"end;" +
"redis.call('xadd', followMessageQueueKey, '*', 'userId', userId, 'followUserId', followUserId, 'status', status); " +
"");
stringRedisTemplate.execute(redisScript, Arrays.asList(USER_FOLLOWS, USER_STREAM_FOLLOW), UserHolder.getUser().getId().toString(), followUserId.toString(), status.toString());
}
查询是否关注了某用户:
@Override
public int isFollow(Long userId) {
Double exists = stringRedisTemplate.opsForZSet().score(USER_FOLLOWS + UserHolder.getUser().getId().toString(), userId.toString());
return Optional.ofNullable(exists).orElse(Double.valueOf(0)) > 0 ? 1 : 0;
}
查询和某用户的共同关注:
@Override
public List<UserDTO> commonFollowFromCache(Long followedUserId) {
String followUserKey = USER_FOLLOWS + followedUserId;
Set<String> followIds = stringRedisTemplate.opsForZSet().reverseRange(followUserKey, 0, -1);
if (Objects.isNull(followIds) || followIds.isEmpty()) {
return Collections.emptyList();
}
String currentUserKey = USER_FOLLOWS + UserHolder.getUser().getId();
Set<String> currentIds = stringRedisTemplate.opsForZSet().reverseRange(currentUserKey, 0, -1);
List<String> interactionIds = interact(followIds.stream().collect(Collectors.toList()), currentIds.stream().collect(Collectors.toList()));
return userService.findByIds(interactionIds);
}
public <T> List<T> interact(List<T> list, List<T> other) {
List<T> largerList = list.size() == other.size() ? list : (list.size() > other.size() ? list : other);
List<T> smallerList = list.size() == other.size() ? other : (list.size() < other.size() ? list : other);
int i = 0;
int index = 0;
while (index < smallerList.size()) {
T currentId = smallerList.get(index);
if (largerList.contains(currentId)) {
smallerList.set(i, currentId);
i++;
}
index++;
}
return smallerList.subList(0, i);
}
关注数据也要落库的 开启一个线程轮询消费:
@PostConstruct
private void initJob() {
threadPoolExecutor.submit(new UserFollowJob());
}
private class UserFollowJob implements Runnable {
@Override
public void run() {
while (true) {
try {
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(USER_STREAM_FOLLOW, ReadOffset.lastConsumed())
);
if (Objects.isNull(records) || records.isEmpty()) {
continue;
}
MapRecord<String, Object, Object> record = records.get(0);
Map<Object, Object> entries = record.getValue();
Long userId = Long.valueOf(entries.get("userId").toString());
Long followUserId = Long.valueOf((String) entries.get("followUserId"));
Integer status = Integer.valueOf((String) entries.get("status"));
log.info("消费关注数据: userId: {}, followUserId: {}, status: {}", userId, followUserId, status);
boolean success = this.updateFollow(userId, followUserId, status);
if (success) {
stringRedisTemplate.opsForStream().acknowledge(USER_STREAM_FOLLOW, "g1", record.getId());
}
} catch (Exception e) {
log.info("handle update user follow exception: {}", e);
handlePendingList();
}
}
}
private boolean updateFollow(Long userId, Long followUserId, Integer status) {
RLock lock = redissonClient.getLock("lock:user:update:follow:" + userId);
try {
boolean tryLock = lock.tryLock(2L, TimeUnit.SECONDS);
if (tryLock) {
Follow newOrUpdatedFollow = new Follow();
newOrUpdatedFollow.setUserId(userId);
newOrUpdatedFollow.setFollowUserId(followUserId);
long exists = followMapper.exist(newOrUpdatedFollow);
newOrUpdatedFollow.setStatus(status);
if (exists > 0) {
// 如果存在 更新status
long updated = followMapper.updateFollow(newOrUpdatedFollow);
return updated > 0;
} else {
// 不存在 新插入
long inserted = followMapper.insertFollow(newOrUpdatedFollow);
return inserted > 0;
}
}
return false;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (lock.isHeldByCurrentThread() && lock.isLocked()) {
lock.unlock();
}
}
}
private void handlePendingList() {
int retries = 5;
while (retries-- > 0) {
try {
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(USER_STREAM_FOLLOW, ReadOffset.from("0"))
);
if (Objects.isNull(records) || records.isEmpty()) {
break;
}
MapRecord<String, Object, Object> record = records.get(0);
Map<Object, Object> entries = record.getValue();
Long userId = Long.valueOf(entries.get("userId").toString());
Long followUserId = Long.valueOf((String) entries.get("followUserId"));
Integer status = Integer.valueOf((String) entries.get("status"));
log.info("消费关注数据: userId: {}, followUserId: {}, status: {}", userId, followUserId, status);
boolean success = this.updateFollow(userId, followUserId, status);
if (success) {
stringRedisTemplate.opsForStream().acknowledge(USER_STREAM_FOLLOW, "g1", record.getId());
}
} catch (Exception e) {
log.info("handle update user follow exception: {}", e);
try {
Thread.sleep(20);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
log.info("handle update user follow failure");
}
}