Spring开发社交模块小记

一、引言

社交模块作为热点数据来说,可能会频繁改动字段,因此用Mysql是肯定不现实的,一般使用Redis。这里我以发表朋友圈动态为例,社交模块包括发表动态,点赞、评论、收藏、关注以及签到统计等模块,这里我简单实现了动态发表,点赞、评论这三个模块。

关注功能模块,使用Redis集合Set,一个人两个集合数据,定时更新到数据库

https://blog.csdn.net/INGNIGHT/article/details/107066022
https://www.cnblogs.com/linjiqin/p/12828315.html

点赞、收藏模块,Set(点赞视频、点赞人评论)和Hash(like::url =1或0)结构都比较合适

https://juejin.cn/post/6904816415912493069#heading-10
https://juejin.cn/post/6895185457110319118#heading-20
https://juejin.cn/post/6844903967168675847

评论模块,可以选择list,用list和zset存储id,其他存储内容

https://juejin.cn/post/6844903709374169102
https://blog.csdn.net/qq171563857/article/details/107406409
https://symonlin.github.io/2019/07/29/redis-1/

登录统计、签到,使用Redis的Bitmap

https://juejin.cn/post/6990152493099384869

二、数据库设计

数据库自行参考,可以考虑持久化到数据库。这里说一下我的设计思路:

动态分为视频动态和图片形式的动态,类似于抖音和微信朋友圈,该模块单独编写,需要信息从其他模块获取;评论为二级评论,后端包装后返回,评论可以点赞等操作;点赞优先经过Redis,若没有查询数据库

create database if not exists lamp_social;
use lamp_social;
-- 评论表
drop table if exists social_comment;
CREATE TABLE social_comment (
comment_id int(11) NOT NULL AUTO_INCREMENT COMMENT '评论表id',
owner_id int(11) NOT NULL COMMENT '文章或视频id',
user_id int(11) NOT NULL COMMENT '用户id',
content text COMMENT '评论内容',
star_num int(11) not null default 0 COMMENT '点赞数量',
p_comment_id int(11) NOT NULL DEFAULT 0 COMMENT '若父评论则为0,默认一级评论;子评论对应其相应的评论父Id',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示未审核,1表示审核通过,2表示不通过',
type int(2) NOT NULL DEFAULT 0 COMMENT '评论类型,默认为0,可以是对人、对资源、对视频等,暂时不用',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
deleted tinyint not null default 0 comment '数据删除位 0正常 1逻辑删除',
primary key(comment_id)
)AUTO_INCREMENT=1 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';


-- 个人动态表
drop table if exists social_dynamic;
CREATE TABLE social_dynamic (
dynamic_id int(11) NOT NULL AUTO_INCREMENT COMMENT '动态id',
dynamic_url varchar(5000) default '' COMMENT '视频地址,若是图片朋友圈,那么地址中间用|进行分隔',
user_id int(11) NOT NULL COMMENT '用户id',
content text COMMENT '朋友圈内容',
star_num int(11) NOT NULL default 0 COMMENT '点赞数量',
collection_num int(11) NOT NULL default 0 COMMENT '收藏数',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示未审核,1表示审核通过,2表示不通过',
type int(2) NOT NULL DEFAULT 0 COMMENT '动态类型,默认为0,表示视频,1表示图片朋友圈,每个数字可以对应不同视频类型',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
deleted tinyint not null default 0 comment '数据删除位 0正常 1逻辑删除',
primary key(dynamic_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人动态表';


-- 用户表可以添加一个点赞数字段,可选
drop table if exists social_user;
CREATE TABLE social_user (
user_id int(11) NOT NULL comment '用户id',
school_id int(11) NOT NULL comment '学校id',
star_num int(11) NOT NULL default 0 COMMENT '点赞数量',
focus_num int(11) NOT NULL default 0 COMMENT '关注数量',
fan_num int(11) NOT NULL default 0 COMMENT '粉丝数量',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
primary key(user_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户点赞表';


-- 用户点赞表
drop table if exists social_user_like_dynamic;
CREATE TABLE social_user_like_dynamic (
liked_id int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
dynamic_id int(11) NOT NULL COMMENT '动态id',
user_id int(11) NOT NULL COMMENT '用户id',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示点赞,1表示取消点赞',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
primary key(liked_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户点赞表';


-- 用户收藏表
drop table if exists social_user_collect_dynamic;
CREATE TABLE social_user_collect_dynamic (
collection_id int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
dynamic_id int(11) NOT NULL COMMENT '动态id',
user_id int(11) NOT NULL COMMENT '用户id',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示收藏,1表示取消收藏',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
primary key(collection_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户收藏表';

-- 用户关注与粉丝表
drop table if exists social_user_focus;
CREATE TABLE social_user_focus (
focus_id int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
user_id int(11) NOT NULL COMMENT '用户id',
focus_user_id int(11) NOT NULL COMMENT '关注用户id',
state int(2) NOT NULL DEFAULT 0 COMMENT '默认0,表示关注,1表示取消关注',
create_time timestamp not null default CURRENT_TIMESTAMP comment '创建时间',
update_time timestamp not null default CURRENT_TIMESTAMP comment '修改时间',
primary key(focus_id)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注与粉丝表';


三、动态发表模块设计

1、介绍

Feed流产品在我们手机APP中几乎无处不在,常见的Feed流比如微信朋友圈、新浪微博、今日头条等。对Feed流的定义,可以简单理解为只要大拇指不停地往下划手机屏幕,就有一条条的信息不断涌现出来。

大多数Feed流产品都包含两种Feed流,一种是基于算法推荐,另一种是基于关注(好友关系)。例如下图中的微博和知乎,顶栏的页卡都包含“关注”和“推荐”这两种。两种Feed流背后用到的技术差别会比较大(读扩散、写扩散)。

参考:https://cloud.tencent.com/developer/article/1744756

2、Redis结构选择

动态发布因为考虑到先缓存到Redis,在异步保存到MySql,因此动态主键使用Redis的自增函数,通过Redis生成MySql的动态主键;

对于动态数据的存储,我使用了list存储结构,新的数据从左边压入list,考虑到feed流查询,我还设置了一个伴生list列表,用来与动态同步存储主键值,首先通过lastid查询上一次浏览的值,查询list的index,在通过存储动态的列表返回一个列表;同时使用了读写锁,是为了保证原子性;

最后异步或定时检查列表长度,若过长可以从右边舍弃,或者设置列表过期时间,插入的时候重新刷新过期时间
Spring开发社交模块小记_第1张图片

四、评论模块设计

1、介绍

mysql表字段,评论父表和字表存储在同一个数据表,根据p_comment_id字段分辨,返回的时候先查询出总的list,在使用JDK8的Stream流形成树形结构返回。ORM映射使用了Fluent MyBatis ,树形结构格式转换;

2、对象类型转换工具

首先创建转换工具类,这里先将对象转化为json,在通过解析json进行复制操作

import com.alibaba.fastjson.JSON;
import java.util.List;
/**
 * 两个对象或集合同名属性赋值
 */
public class ObjectConversion {

    /**
     * 从List copy到List
     * @param list List
     * @param clazz B
     * @return List
     */
    public static <T> List<T> copy(List<?> list,Class<T> clazz){
        String oldOb = JSON.toJSONString(list);
        return JSON.parseArray(oldOb, clazz);
    }

    /**
     * 从对象A copy到 对象B
     * @param ob A
     * @param clazz B.class
     * @return B
     */
    public static <T> T copy(Object ob,Class<T> clazz){
        String oldOb = JSON.toJSONString(ob);
        return JSON.parseObject(oldOb, clazz);
    }
}

3、多级评论树型拼接

我的VO类,主要用将数据库的评论拼装返回前端

@Data
@Accessors(chain = true)
public class VideoCommentVO {
    /**
     * 评论表id
     */
    private Integer commentId;

    /**
     * 创建时间
     */
    private Date createTime;
    
    /**
     * 评论内容
     */
    private String content;

    /**
     * 文章或视频id
     */
    private Integer ownerId;

    /**
     * 若父评论则为0,默认一级评论;子评论对应其相应的评论父Id
     */
    private Integer pCommentId;

    /**
     * 点赞数量
     */
    private Integer starNum;

    /**
     * 用户id
     */
    private Integer userId;

    /**
     * 孩子
     */
    private List<VideoCommentVO> child;
}

树形结构拼装,用了jdk8新特性

@Service
public class VideoCommentService {

    @Autowired
    CommentMapper commentMapper;


    //就先二级评论吧
    public List<VideoCommentVO> getVideoComment(Integer videoId) {
        CommentQuery query = new CommentQuery()
                .where().ownerId().eq(videoId).end()
                .where().state().eq(0).end()
                .where().deleted().eq(0).end();
        List<CommentEntity> commentEntities = commentMapper.listEntity(query);
        //列表拷贝
        List<VideoCommentVO> videoCommentVOList = ObjectConversion.copy(commentEntities, VideoCommentVO.class);
        //列表通过pcommentid进行分组
        Map<Integer, List<VideoCommentVO>> collect = videoCommentVOList.stream().collect(Collectors.groupingBy(VideoCommentVO::getPCommentId));
        //分组后遍历每一个数组设置孩子
        videoCommentVOList.forEach(
                videoComment->videoComment.setChild(collect.get(videoComment.getCommentId()))
        );
        System.out.println(videoCommentVOList);
        //找出父结点并返回,排序默认从小到大
        List<VideoCommentVO> result = videoCommentVOList.stream()
                .filter(s -> s.getPCommentId().equals(0))
                .sorted(Comparator.comparing(VideoCommentVO::getStarNum).reversed())
                .collect(Collectors.toList());

        return result;
    }
}

如果遇到下面问题,回退版本号,我当时遇到了

// fastJson1.2.78版本会概率性出现该错误,回退到1.2.76即可
Comparison method violates its general contract

4、评论简单过滤

Spring开发社交模块小记_第2张图片

简单原理如上图所示,创建结点类,里面包含是否是敏感词结束符,以及一个HashMap,哈希里key值存储的是敏感词的一个词,value指向下一个结点(即指向下一个词),一个哈希表中可以存放多个值,比如赌博、赌黄这两个都是敏感词。

敏感词文件存在在resources文件夹下,通过类加载器获取里面的敏感词。在springboot中,被@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。PostConstruct在构造函数之后执行,init()方法之前执行。

/**
 * 敏感词过滤器
 *
 * @author Shawn
 * @date 2021年11月20日11:09
 **/
@Component
public class SensitiveFilter {

    private static final Logger logger = LoggerFactory.getLogger(SensitiveFilter.class);

    /**
     * 将敏感词替换成 ***
     */
    private static final String REPLACEMENT = "***";

    private final TreeNode rootNode = new TreeNode();


    /**
     * 被@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。PostConstruct在构造函数之后执行,init()方法之前执行。
     * 初始化敏感词的结构树
     */
    @PostConstruct
    public void init(){
        // 带资源的try语句,try块退出时,会自动调用res.close()方法,关闭资源。
        try (
            InputStream resourceAsStream = this.getClass().getClassLoader().getResourceAsStream("sensitive-words.txt");
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(resourceAsStream));
        ){
            String keyword;
            while((keyword=bufferedReader.readLine())!=null){
                this.addKeyWord(keyword);
            }
        } catch(IOException e){
            logger.error("资源文件加载失败 ==> {}",e.getMessage());
        }
    }

    /**
     * 将敏感词加入前缀树里
     * @param keyword 敏感词
     */
    private void addKeyWord(@NotNull String keyword){
        TreeNode tempNode = rootNode;
        for(int i = 0 ;i<keyword.length();i++){
            char c = keyword.charAt(i);
            // 进行空值判断,当多个敏感词首字母相同时,可以指向不同结点
            TreeNode subNode = tempNode.getKeywordNode(c);
            if(subNode==null){
                subNode = new TreeNode();
                tempNode.addKeywordNode(c,subNode);
            }
            // 指向下一个结点
            tempNode=subNode;
        }
        // 结尾设置结束符
        tempNode.setKeywordEnd(true);
    }

    /**
     * 敏感词过滤器
     * @param text 文本
     * @return {@link String}
     */
    public String filter(String text){
        if(StringUtils.isBlank(text)){
            return null;
        }
        // 规则树,用来匹配敏感词
        TreeNode tempNode = rootNode;
        // begin指针,指向文本中某个敏感词的第一位
        int begin=0;
        // end指针,指向文本中某个敏感词的最后一位
        int end = 0;

        StringBuilder sb = new StringBuilder();
        while (end<text.length()){
            char c = text.charAt(end);
            // 跳过符号(防止敏感词混合符号,比如 ☆赌☆博)
            if(this.isSymbol(c)){
                // 若tempNode结点在开头,代表还没有匹配敏感字,这个特殊符号加入返回词,且 begin 指针指向下一个
                if(tempNode == rootNode){
                    sb.append(c);
                    begin++;
                }
                // 无论符号在开头还是在中间,指针 end 都会向下走一步
                end++;
                continue;
            }
            // 检查子节点
            tempNode = tempNode.getKeywordNode(c);
            if(tempNode==null){
                // 以指针 begin 开头的字符串不是敏感词
                sb.append(text.charAt(begin));
                // 进入下一位的判断
                end++;
                begin=end;
                // 这里需要把规则树重新指向根节点
                tempNode=rootNode;
            }else if(tempNode.isKeyWordEnd()){
                // 发现敏感词,将 begin~end 的字符串替换掉
                sb.append(REPLACEMENT);
                end++;
                begin=end;
                tempNode=rootNode;
            }else{
                // 敏感词匹配过程中
                end++;
            }
        }

        // 将最后一批字符计入结果(如果最后一次循环的字符串不是敏感词,上述的循环逻辑不会将其加入最终结果)
        sb.append(text.substring(begin));
        return sb.toString();
    }


    /**
     * 是否是符号
     */
    private boolean isSymbol(Character c){
        // 0x2E80~0x9FFF 是东亚文字范围
        return !CharUtils.isAsciiAlphanumeric(c) && (c < 0x2E80 || c > 0x9FFF);
    }


    /**
     * 树节点
     * 敏感词结点类,与hash表类似,所有敏感词形成一个hash表,每个词语的每个词形成一个链表,用map指向下一个
     */
    private static class TreeNode{
        // 关键词结束标识,默认是不是非结束结点
        private boolean isKeywordEnd = false;
        // map的key存储一个敏感词,value指向下一个敏感词结点
        HashMap<Character, TreeNode> nodeMap = new HashMap<>();

        // 返回是否本次词语结束
        public boolean isKeyWordEnd(){
            return isKeywordEnd;
        }
        // 设置是否是结束词
        public void setKeywordEnd(boolean keywordEnd){
            isKeywordEnd = keywordEnd;
        }
        // 添加敏感词,key表示字符
        public void addKeywordNode(Character c, TreeNode treeNode){
            nodeMap.put(c,treeNode);
        }
        // 获取当前词是否是敏感词,若没有在表中,则返回null
        public TreeNode getKeywordNode(Character c){
            return nodeMap.get(c);
        }
    }

}

五、点赞模块设计

1、问题描述

考虑到点赞是字段频繁变动的,用Mysql肯定不合适,使用需要使用Redis内存数据库。这里以动态点赞为例子,点赞模块需要解决的几个问题

  • 用户对某个动态点赞/取消点赞
  • 该动态获得了多少赞
  • 用户是否已经点赞该动态
  • 用户的总点赞数是多少
  • 数据的持久化

2、Redis数据结构选择

对于点赞来说,Set和Hash结构都可以选择。set中的值不能重复,是无序不重复的,Hash相当于Map集合,相当于key-Map,通常来存储经常变动的对象。对于点赞,两种结构都可以,根据业务自由选择

1. Set结构存储

这里我选择了一种较为简单的存储方案,不过这种方案很难进行MySql持久化,用Set结构存储某视频点赞的用户,用String结构存储用户点赞数量,查询用户是否点赞只需查询用户是否在这个Set集合里,点赞/取消点赞加入/移除Set,查询某视频点赞数只需统计Set集合中的用户数量,两个存储结构为

  • 视频点赞Set的存储结构 like:dynamic:{dynamicType}:{dynamicId}={userId}
  • 用户点赞数量的String存储结构 like:user:{userId}=value

2. Hash结构存储

这种Hash结构可以记录点赞人和被点赞产品,还有点赞状态(点赞/取消点赞设置值为1/0),还可以固定时间间隔取出 Redis 中所有点赞数据

  • 视频点赞Hash的Key结构like:dynamic:{dynamicType},里面的键值对为{userId}::{dynamicId}=1
  • 视频点赞数Hash的Key结构like:count:dynamic,里面的键值对为{dynamicId}={count}
  • 用户点咱叔Hash的Key结构like:count:user,里面的键值对为{userId}={count}

3、编码实现

1. 配置redis

首先进行redis配置,实现序列化,否则不能正常显示

/**
 * @author Shawn
 * @date 2021年11月19日13:22
 **/
@Configuration
public class RedisConfig {
    // 编写自己的RedisTemplate
    @Bean
    @SuppressWarnings("all")
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        //序列化配置,默认会报错类型转换错误
        //FastJsonRedisSerializer fastJsonRedisSerializer = new FastJsonRedisSerializer<>(Object.class);
        FastJson2JsonRedisSerializer fastJsonRedisSerializer = new FastJson2JsonRedisSerializer<>(Object.class);
        ObjectMapper om = new ObjectMapper();
        //String的序列化
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        // hash采用String序列方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value采用jackson
        template.setValueSerializer(fastJsonRedisSerializer);
        // hash的value采用jackson
        template.setHashValueSerializer(fastJsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }
}

创建FastJson2JsonRedisSerializer

public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T> {
    public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
    static {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }
    private final Class<T> clazz;
    public FastJson2JsonRedisSerializer(Class<T> clazz) {
        super();
        this.clazz = clazz;
    }
    /**
     * 序列化
     */
    @Override
    public byte[] serialize(T t) throws SerializationException {
        if (null == t) {
            return new byte[0];
        }
        // 序列化时写入类型信息,默认为false,反序列化是需用到
        // 如果序列化是没有加入类型信息SerializerFeature.WriteClassName,就会报错java.lang.ClassCastException
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }
    /**
     * 反序列化
     */
    @Override
    public T deserialize(byte[] bytes) throws SerializationException {
        if (null == bytes || bytes.length <= 0) {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);
        return (T) JSON.parseObject(str, clazz);
    }
}

2. Redis工具类编写

新建RedisKeyUtil,进行key的拼接

/**
 * 返回redis的key
 * 每个视频点赞用set进行保存,里面的用户数就是点赞数
 * 用户的点赞数用k-v保存
 * @author Shawn
 * @date 2021年11月19日13:52
 **/
public class RedisKeyUtil {
    /**
     * redis分隔符
     */
    private static final String SPLIT = ":";
    /**
     * 动态的获赞
     */
    private static final String PREFIX_DYNAMIC_LIKE = "like:dynamic";
    /**
     * 动态的获赞数
     */
    private static final String PREFIX_DYNAMIC_LIKE_COUNT = "like:count:dynamic";
    /**
     * 用户的获赞
     */
    private static final String PREFIX_USER_LIKE = "like:count:user";
   
    /**
     * 返回redis视频点赞set key值
     * 举例like:dynamic:1:11
     * @param dynamicType 动态类型
     * @param dynamicId 视频id
     * @return {@link String}
     */
    public static String getDynamicLikeKey(int dynamicType,int dynamicId){
        return PREFIX_DYNAMIC_LIKE + SPLIT + dynamicType + SPLIT + dynamicId;
    }

    /**
     * 返回redis视频点赞key值,使用hash
     * 举例hashkey为 like:dynamic:1
     * value为 dynamicId:userId(1111::2222)
     * @param dynamicType 动态类型
     * @return {@link String}
     */
    public static String getDynamicLikeHashKey(int dynamicType){
        return PREFIX_DYNAMIC_LIKE + SPLIT + dynamicType;
    }

    /**
     * 动态的获赞数的key值,使用hash
     * hashkey是动态id
     * 定时更新到数据库
     * @return {@link String}
     */
    public static String getDynamicLikeCountHashKey(){
        return PREFIX_DYNAMIC_LIKE_COUNT ;
    }

    /**
     * hash的用户key (userId::dunamicId)
     * 4444::3333
     */
    public static String getDynamicUserLikeHashKey(int userId, int dynamicId){
        return userId + SPLIT + SPLIT + dynamicId ;
    }


    /**
     * 用户总的点赞数key
     * 举例like:user:2
     * @param userId 用户id
     * @return {@link String}
     */
    public static String getUserLikeKey(int userId){
        return PREFIX_USER_LIKE + SPLIT + userId;
    }

    /**
     * 用户总的点赞数key hash
     * 举例like:count:user
     * @return {@link String}
     */
    public static String getUserLikeHashKey(){
        return PREFIX_USER_LIKE;
    }

}

3. 使用Set结构存储点赞

redis点赞模块service代码,自己写的,没有持久化,仅供参考

@Autowired
private RedisTemplate redisTemplate;


/**
 * 某个视频用户是否点赞
 *
 * @param dynamicType 动态类型
 * @param userId    用户id
 * @param dynamicId   动态id
 * @return {@link Boolean}
 */
public Boolean getDynamicIsLikeByUser(int dynamicType, int userId,int dynamicId){
    String dynamicLikeKey = RedisKeyUtil.getDynamicLikeKey(dynamicType,dynamicId);
    Boolean member = redisTemplate.opsForSet().isMember(dynamicLikeKey, userId);
    return member;
}

/**
 * @param userId  用户id
 * @param dynamicId 动态id
 * @return boolean
 */
public boolean putDynamicLikedByRedis(int dynamicType, int userId,int dynamicId){
    redisTemplate.execute(new SessionCallback() {
          @Override
          public Object execute(RedisOperations operations) throws DataAccessException {

              String dynamicLikeKey = RedisKeyUtil.getDynamicLikeKey(dynamicType,dynamicId);
              String userLikeKey = RedisKeyUtil.getUserLikeKey(userId);

              Boolean member = redisTemplate.opsForSet().isMember(dynamicLikeKey, userId);
              //开启redis事务
              redisTemplate.multi();
              //如果已经点过赞了,就去除
              if(Boolean.TRUE.equals(member)){
                  redisTemplate.opsForSet().remove(dynamicLikeKey,userId);
                  redisTemplate.opsForValue().decrement(userLikeKey);
              }else{
                  //如果没有点赞,就点赞
                  redisTemplate.opsForSet().add(dynamicLikeKey,userId);
                  redisTemplate.opsForValue().increment(userLikeKey);
              }
              // 返回每条成功执行的记录
              redisTemplate.exec();
              return true;
          }
      }
    );
    return true;
}

/**
 * 获取视频点赞数
 * @param dynamicType 动态类型
 * @param dynamicId   动态id
 * @return {@link Long}
 */
public Long getDynamicLikeCount(int dynamicType,int dynamicId){
    String dynamicLikeKey = RedisKeyUtil.getDynamicLikeKey(dynamicType,dynamicId);
    Long size = redisTemplate.opsForSet().size(dynamicLikeKey);
    return size;
}

/**
 * 这里有个小坑,如果没有记录,返回的是null
 * @param userId
 * @return
 */
public Integer getUserLikeCount(int userId){
    String userLikeKey = RedisKeyUtil.getUserLikeKey(userId);
    Integer result = (Integer) redisTemplate.opsForValue().get(userLikeKey);
    return result==null ? 0 : result;
}


4. 使用Hash结构存储点赞

@Autowired
private RedisTemplate redisTemplate;
/**
 * 某个视频用户是否点赞
 *
 * @param dynamicType 动态类型
 * @param userId    用户id
 * @param dynamicId   动态id
 * @return {@link Boolean}
 */
public Boolean getDynamicIsLikeByUser1(int dynamicType, int userId,int dynamicId){
    try {
        String dynamicLikeKey = RedisKeyUtil.getDynamicLikeHashKey(dynamicType);
        String dynamicLikeHashkey = RedisKeyUtil.getDynamicUserLikeHashKey(userId,dynamicId);
        // redis Set的值
        String dynamicLikeSetKey = RedisKeyUtil.getDynamicLikeKey(dynamicType,dynamicId);

        // 首先查询缓存
        Object redisResult = redisTemplate.opsForHash().get(dynamicLikeKey, dynamicLikeHashkey);
        // 查询二级缓存Set
        Boolean member = redisTemplate.opsForSet().isMember(dynamicLikeSetKey, userId);
        // 缓存没有查询数据库
        if(redisResult == null && member == false){
            log.warn("redis查询失败");
            throw new SqlException("redis查询失败");
        }

    }catch (SqlException e){
        UserLikeDynamicQuery userLikeDynamicQuery = new UserLikeDynamicQuery()
                .where().dynamicId().eq(dynamicId).end()
                .where().userId().eq(userId).end()
                .where().state().eq(0).end();
        UserLikeDynamicEntity one = userLikeDynamicMapper.findOne(userLikeDynamicQuery);
        if(null == one || one.getState()==0){
            // 数据库查询不到或者状态位0,则没有点赞
            return false;
        }
    }catch (Exception e){
        log.error("点赞模块发生未知错误");
        return false;
    }
    return true;
}

/**
 * 点赞,返回点赞数
 * @param userId  用户id
 * @param dynamicId 动态id
 * @return boolean
 */
@SuppressWarnings("all")
public int putDynamicLikedByRedis1(int dynamicType, int userId, int dynamicId){
    String dynamicLikeKey = RedisKeyUtil.getDynamicLikeHashKey(dynamicType);
    String dynamicLikeHashkey = RedisKeyUtil.getDynamicUserLikeHashKey(userId,dynamicId);
    // 首先查询缓存
    Object redisResult = redisTemplate.opsForHash().get(dynamicLikeKey, dynamicLikeHashkey);
    if(null != redisResult && redisResult.equals(1)){
        return -1;
    }

    Long result = (Long) redisTemplate.execute(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            String dynamicLikeKey = RedisKeyUtil.getDynamicLikeHashKey(dynamicType);
            String dynamicLikeHashkey = RedisKeyUtil.getDynamicUserLikeHashKey(userId, dynamicId);
            String userLikeHashKey = RedisKeyUtil.getUserLikeHashKey();
            // 点赞数记数
            String dynamicLikeCountHashKey = RedisKeyUtil.getDynamicLikeCountHashKey();

            redisTemplate.multi();
            redisTemplate.opsForHash().put(dynamicLikeKey, dynamicLikeHashkey, 1);
            redisTemplate.opsForHash().increment(userLikeHashKey,String.valueOf(userId),1);
            // 自增
            Long increment = redisTemplate.opsForHash().increment(dynamicLikeCountHashKey, String.valueOf(dynamicId), 1);

            List exec = redisTemplate.exec();
            return exec.get(exec.size()-1);
        }
    });


    return result.intValue();
}

/**
 * 取消点赞,返回点赞数
 * @param userId  用户id
 * @param dynamicId 动态id
 * @return boolean
 */
@SuppressWarnings("all")
public int putDynamicDislikedByRedis1(int dynamicType, int userId,int dynamicId){
    String dynamicLikeKey = RedisKeyUtil.getDynamicLikeHashKey(dynamicType);
    String dynamicLikeHashkey = RedisKeyUtil.getDynamicUserLikeHashKey(userId,dynamicId);
    // 首先查询缓存
    Object redisResult = redisTemplate.opsForHash().get(dynamicLikeKey, dynamicLikeHashkey);
    if(null == redisResult || redisResult.equals(0)){
        return -1;
    }
    Long result = (Long) redisTemplate.execute(new SessionCallback() {
        @Override
        public Object execute(RedisOperations operations) throws DataAccessException {
            String dynamicLikeKey = RedisKeyUtil.getDynamicLikeHashKey(dynamicType);
            String dynamicLikeHashkey = RedisKeyUtil.getDynamicUserLikeHashKey(userId, dynamicId);
            String userLikeHashKey = RedisKeyUtil.getUserLikeHashKey();
            // 点赞数记数
            String dynamicLikeCountHashKey = RedisKeyUtil.getDynamicLikeCountHashKey();

            redisTemplate.multi();
            redisTemplate.opsForHash().put(dynamicLikeKey, dynamicLikeHashkey, 0);
            redisTemplate.opsForHash().increment(userLikeHashKey,String.valueOf(userId),-1);
            // 自增
            Long increment = redisTemplate.opsForHash().increment(dynamicLikeCountHashKey, String.valueOf(dynamicId), -1);

            List exec = redisTemplate.exec();
            return exec.get(exec.size()-1);
        }
    });


    return result.intValue();
}

/**
 * 获取点赞数
 * @param dynamicId   动态id
 * @return {@link Long}
 */
public Integer getDynamicLikeCount1(int dynamicId) throws Exception {
    Integer result;
    // 点赞数记数
    String dynamicLikeCountHashKey = RedisKeyUtil.getDynamicLikeCountHashKey();
    try {

        // Hash这里操作都需要String
        Object o = redisTemplate.opsForHash().get(dynamicLikeCountHashKey, String.valueOf(dynamicId));
        // 如果缓存挂了,查询数据库
        if(null == o){
            log.info("redis查询失败");
            throw new Exception("redis查询为空");
        }
        result = (Integer) o;
    }catch (Exception e){

        DynamicEntity byId = dynamicMapper.findById(dynamicId);
        if(null == byId){
            throw new Exception("没有该id动态");
        }
        Integer starNum = byId.getStarNum();
        result=starNum;
        //将结果缓存到redis
        redisTemplate.opsForHash().put(dynamicLikeCountHashKey, String.valueOf(dynamicId), starNum);
    }

    return result;

}

5. Quartz定时任务持久化

对于Hash结构存储的,还可以根据::分离出点赞人和被赞动态,拆分后进行持久化,配置好Quartz定时任务后,下面举例其中一个

// 返回需要插入数据库的列表
public List<UserLikeDynamicEntity> getDBList(int dynamicType) {
    List<UserLikeDynamicEntity> userLikeDynamicEntityList = new ArrayList<>();
    try {
        Cursor<Map.Entry<Object,Object>> cursor = redisTemplate.opsForHash()
                .scan(RedisKeyUtil.getDynamicLikeHashKey(dynamicType), ScanOptions.NONE);

        while(cursor.hasNext()){
            Map.Entry<Object, Object> entry = cursor.next();
            String key = (String) entry.getKey();
            //分离出 UserId,dynamicId
            String[] split = key.split("::");
            Integer userId = Integer.valueOf(split[0]);
            Integer dynamicId = Integer.valueOf(split[1]);
            Integer value = (Integer) entry.getValue();

            String dynamicLikeSetKey = RedisKeyUtil.getDynamicLikeKey(dynamicType, dynamicId);

            // 设置过期,如果热点数据将会一直存在,如果不是会自动删除
            redisTemplate.expire(dynamicLikeSetKey,2, TimeUnit.DAYS);
            redisTemplate.opsForSet().add(dynamicLikeSetKey,userId);

            //组装成 UserLike 对象
            UserLikeDynamicEntity userLikeDynamicEntity = new UserLikeDynamicEntity();
            userLikeDynamicEntity.setUserId(userId).setDynamicId(dynamicId).setState(value);
            userLikeDynamicEntityList.add(userLikeDynamicEntity);

            //存到 list 后从 Redis 中删除
            redisTemplate.opsForHash().delete(RedisKeyUtil.getDynamicLikeHashKey(dynamicType),key);
        }
    cursor.close();
    }catch (Exception e){
        log.error("cursor关闭异常");
    }
    // 返回需要插入的列表
    return userLikeDynamicEntityList;
}

以上是我暂时做的,可能有很多问题,如果有问题,希望能够指出,后期不一定可放源码