场景概述

在实际开发中,点赞是高频操作,如果每一次点赞或者获取点赞数都要查询数据库,将会给数据库造成极大的压力,因此尝试用缓存技术来缓存操作。常用的有redis缓存技术。

现在想要做一个博客系统的点赞功能,现在有用户表user,文章表article,帖子表posts,评论表comment,文章、帖子和评论三种类型统称为“作品”,每一种作品类都点赞数字段。以下仅以文章点赞为例,其他作品类型的点赞功能实现大同小异。

创建文章点赞表article_like_record,这是一个关系表,储存点赞方和被点赞方,以此储存点赞关系。就两个字段:userid,targetId。

CREATE TABLE `article_like_record` (
  `user_id` int unsigned NOT NULL COMMENT '点赞用户id',
  `target_id` int unsigned NOT NULL COMMENT '点赞的文章的id',
  PRIMARY KEY (`user_id`,`target_id`)
);

不同类型的作品独立一个点赞表。所以一共有三个点赞表,分别存储文章、帖子和评论的点赞。当然,也可以合在一个表,用一个新字段type区分。我的看法是点赞是高频操作,点赞记录会很多,如果都挤在一个表,那么这个表不好维护。不过我现在还没有接触专业的储存方式和储存技术,成熟的网站想必有方式处理这个问题,而且这只是个小的博客项目,所以其实用一个表也是挺不错的。

此外,本来,从点赞关系表中使用count函数就可以获取某一个作品的总点赞数,但是那样子需要查询整个表,统计记录数,我个人觉得效率太低,所以就给三种类型的作品表都加了一个点赞数字段。当然,这样做无疑会造成一些数据冗余,不过这也是用空间换时间,我觉得是可以接受的。

思路

数据传输

当用户点赞(或取消点赞)时,通过ajax将行为传输到后端Controller接收。
需要传递的数据以json格式传输:

{
    userid : "1",
    targetId : "2", 		//被点赞的目标作品的id
    targetType: "article", 	//目标作品类型
    likeState : "1" 		//点赞状态,1-点赞 0-未点赞/取消赞
}


redis设计

redis采用hash结构储存点赞记录缓存和点赞数统计缓存。

  • 所谓点赞记录缓存即“是否做了点赞这件事”,最终将持久化到数据库的点赞关系表上,用于表示某个用户是否已经点赞了某个作品。这里储存的是一种行为,或者称之为关系
  • 点赞数量缓存即缓存某一个作品现在有多少点赞数。它缓存的是一个数字,并不能表示哪个用户点赞了哪个表。这储存的是一种数据

redis的hash可以指定一个Key,因此我们使用likeRecordlikeCount区分上述两种缓存。

redis的fieldname要求也为字符串。所以我们将Key为likeRecord的value的储存格式规定为为形如"targetType::userid::targetId"的字符串,而value则储存1或者0,分别表示点赞或者取消点赞,比如99999::12345::1的value为1,表示用户12345对文章99999做了点赞操作。这样就可以储存“谁对谁做了什么”这一个行为。

likeCount更加简单,其对应的fieldname设置为"targetType::targetId",即作品类型和作品id,而value设置为作品的当前点赞数即可。


数据更新

现在,reids的储存结构已经设计好了,那么在点赞和取消点赞的时候要怎么操作呢?

后端获取数据后,根据targetType和likeState执行对应操作,将点赞/取消点赞的行为储存在redis中。同时更新点赞数缓存。

点赞

首先要根据targetType、userid和targetId拼接fieldname,然后通过jedis的hget方法获取对应的value。

  • 如果value为1,则说明该用户已经点赞。也就是说当前该用户重复点赞了,此时不执行任何操作。
  • 如果value不为1,则有两种可能,第一是redis中没有缓存记录;第二是value为0,表示该用户之前取消过点赞,此时又再次点赞。这两种情况下我们都要修改缓存记录,将value修改为1

之后,还要修改点赞数缓存likeCount,可以通过jedis.hincrBy方法使对应的fieldname的value自增1,即jedis.hincrBy("likeCount", likeCountFieldName, 1L);

取消点赞

取消点赞和点赞操作大同小异,注意取消点赞时不能删除缓存记录,而要把对应的value设置为0。原因如下:

前文提到,我们要缓存的是“点赞的行为”,也就是说我们必须将“取消点赞”这一行为记录下来。最终我们的数据要持久化到数据库中,届时如果从reids中获取到取消点赞的缓存记录(即value为0),我们就可以将数据库的点赞记录删去,但是如果我们在取消点赞时直接删除缓存记录,那么在持久化的时候我们就会遗漏这一行为。所以在取消点赞时不能删除缓存记录,而要把对应的value设置为0。

redis持久化

使用ScheduledThreadPoolExecutor进行定时任务,定时持久化数据至数据库中。可以通过TomcatListener在服务器启动时启动定时任务。

在实现了Runnable的子类LikeRunnable中实例化Service对象,调用三种类型的DAO,并获取jedis对象,调用其hgetAll方法,获取所有点赞数据的Map,包括likeRecordlikeCount。之后通过DAO持久化。

持久化的主要问题是对ScheduledThreadPoolExecutor的理解,至于持久化操作时Service和DAO的事情,与普通的持久化操作别无二致。

代码部分

实体类
public class LikeRecord {

	/**
	 * 数据库主键
	 */
	private Long id;
	/**
	 * 点赞的用户账号
	 */
	private Long userid;
	/**
	 * 点赞的目标编号
	 */
	private Long targetId;
	/**
	 * 目标类型 文章/评论/帖子
	 */
	private int targetTypeInt;
	/**
	 * 点赞状态
	 * 1 为 点赞
	 * 0 为 取消点赞或者未点赞
	 */
	private int likeState;

	// 类型枚举
	private TargetType targetType;

	// setter和getter省略
}

枚举类
作品枚举
public enum TargetType {
	/** 文章 */
	ARTICLE(1, "article"),
	/** 帖子 */
	POSTS(2, "posts"),
	/** 评论 */
	COMMMENT(3, "comment"),
	;

	private final int CODE;
	private final String VALUE;

	TargetType(int code, String value) {
		CODE = code;
		VALUE = value;
	}

	public int code() {
		return CODE;
	}

	public String val() {
		return VALUE;
	}

	/**
	 * 通过字符串获取数值
	 * @param value
	 * @return code
	 */
	public static int getCode(String value) {
		for (TargetType p : TargetType.values()) {
			if (p.val().equals(value)) {
				return p.code();
			}
		}
		return -1;
	}

	/**
	 * 通过字符串获取枚举
	 * @param value
	 * @return
	 */
	public static TargetType getTargetType(String value) {
		for (TargetType p : TargetType.values()) {
			if (p.val().equals(value)) {
				return p;
			}
		}
		return null;
	}

	/**
	 * 通过数字获取枚举
	 * @param value
	 * @return
	 */
	public static TargetType getTargetType(int value) {
		for (TargetType p : TargetType.values()) {
			if (p.code() == value) {
				return p;
			}
		}
		return null;
	}
}
点赞类型枚举
public class LikeEnum {
	/** redis(key) 点赞记录缓存 */
	public static final String KEY_LIKE_RECORD = "likeRecord";
	/** redis(key) 点赞数缓存 */
	public static final String KEY_LIKE_COUNT = "likeCount";

	/** 已点赞 */
	public static final String HAVE_LIKED = "1";

	/** 未点赞 */
	public static final String HAVE_NOT_LIKED = "0";
}

Controller层

Controller的工作是:接收请求参数、判断空参和用户登录状态、调用Service层,以及返回响应结果

@WebServlet("/LikeServlet")
public class LikeController extends BaseServlet {

	@Override
	protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

		LikeRecord record = GetParamChoose.getObjByJson(req, LikeRecord.class);
		//空参检查
		if (record == null) {
			// 如果为空参,则通过自己写的策略模式的方法返回请求的响应结果
			ResponseChoose.respNoParameterError(resp, "点赞");
			return;
		}

		Long userId = ControllerUtil.getUserId(req);
		if (userId == null) {
			logger.error("点赞时用户未登录");
			ResponseChoose.respUserUnloggedError(resp);
			return;
		}
		record.setUserid(userId);

		//点赞
		LikeService service = ServiceFactory.getLikeService();
		ResultType resultType = null;
		try {
			resultType = service.likeOrUnlike(record);
		} catch (Exception e) {
			e.printStackTrace();
		}
		
		//自己写的策略模式,返回请求的响应结果
		ResponseChoose.respOnlyStateToBrowser(resp, resultType, "点赞操作");

	}
}

Service层

Service接口
public interface LikeService {

	/**
	 * 点赞
	 * @param likeRecord
	 * @return
	 * @throws Exception
	 */
	ResultType likeOrUnlike(LikeRecord likeRecord) throws Exception;

	/**
	 * 点赞关系记录持久化到数据库点赞表中
	 * @throws Exception
	 */
	void persistLikeRecord() throws Exception;

	/**
	 * 点赞数量统计持久化到数据库作品表中
	 * @throws Exception
	 */
	void persistLikeCount()  throws Exception;
}
实现类

Service实现类的工作是:判断行为类型(点赞/取消点赞),通过策略模式完成操作;同时也负责持久化的DAO调用

/**
 * @author 寒洲
 * @description 点赞service
 */
public class LikeServiceImpl implements LikeService {

	private Logger logger = Logger.getLogger(LikeServiceImpl.class);

	LikeDao articleLikeDao;
	LikeDao postsLikeDao;
	LikeDao commentLikeDao;

	@Override
	public ResultType likeOrUnlike(LikeRecord likeRecord) throws Exception {
		Connection conn = JdbcUtil.getConnection();
		//检查
		if (likeRecord.getTargetType() == null) {
			logger.error("点赞类型为null 异常!");
			throw new Exception("点赞类型为null");
		}
		//获取属性
		Long userid = likeRecord.getUserid();
		Long targetId = likeRecord.getTargetId();
		int likeState = likeRecord.getLikeState();
		TargetType likeType = likeRecord.getTargetType();

		if (likeState == 1) {
			//想要点赞
			LikeStategyChoose stategyChoose = new LikeStategyChoose(new LikeStrategyImpl());
			stategyChoose.likeOperator(userid, targetId, likeType);
		} else if (likeState == 0) {
			//想要取消点赞
			LikeStategyChoose stategyChoose = new LikeStategyChoose(new CancelLikeStrategyImpl());
			stategyChoose.likeOperator(userid, targetId, likeType);
		}
		return ResultType.SUCCESS;
	}

	@Override
	public void persistLikeRecord() throws Exception {
		logger.info("储存用户点赞关系");
		Connection conn = JdbcUtil.getConnection();
		Jedis jedis = JedisUtil.getJedis();
		Map<String, String> redisLikeData = jedis.hgetAll(LikeEnum.KEY_LIKE_RECORD);

		//实例化三个点赞DAO
		createDaoInstance();

		//获取键值
		for (Map.Entry<String, String> vo : redisLikeData.entrySet()) {

			String likeRecordKey = vo.getKey();
			LikeRecord likeRecord = getLikeRecord(likeRecordKey);

			String value = vo.getValue();

			//根据不同的类型使用不同的预设DAO
			LikeDao dao = getLikeDaoByTargetType(likeRecord.getTargetType());

			//检查数据库的点赞状态,true为存在点赞记录
			boolean b = dao.countUserLikeRecord(conn, likeRecord);

			if (LikeEnum.HAVE_LIKED.equals(value)) {
				//储存点赞记录
				if (!b) {
					//未点赞,添加记录
					dao.createLikeRecord(conn, likeRecord);
					logger.trace("添加点赞记录");
				}
				//else 已点赞,不操作

			} else if (LikeEnum.HAVE_NOT_LIKED.equals(value)) {
				//删除点赞记录
				if (b) {
					//数据库存在用户点赞记录,删除该记录,取消点赞
					dao.deleteLikeRecord(conn, likeRecord);
					logger.trace("删除点赞记录");
				}
			}
		}
		//在缓存数据都成功添加到数据库后再删除数据,防止回滚丢失数据
		for (String key : redisLikeData.keySet()) {
			//根据key移除
			jedis.hdel(LikeEnum.KEY_LIKE_RECORD, key);
		}
	}

	/**
	 * 实例化三个点赞DAO
	 */
	private void createDaoInstance() {
		articleLikeDao = DaoFactory.getLikeDao(TargetType.ARTICLE);
		postsLikeDao = DaoFactory.getLikeDao(TargetType.POSTS);
		commentLikeDao = DaoFactory.getLikeDao(TargetType.COMMMENT);
	}

	/**
	 * 根据不同的类型使用不同的DAO
	 * @param type
	 * @return
	 */
	private LikeDao getLikeDaoByTargetType(TargetType type) {
		LikeDao dao;
		//判断请求的类型
		switch (type) {
			case ARTICLE:
				dao = articleLikeDao;
				break;
			case POSTS:
				dao = postsLikeDao;
				break;
			default:
				dao = commentLikeDao;
		}
		return dao;
	}

	@Override
	public void persistLikeCount() throws Exception {
		Connection conn = JdbcUtil.getConnection();
		Jedis jedis = JedisUtil.getJedis();
		// 获取所有缓存的点赞键值对(包含了目标对象的类型和id以及缓存的点赞数)
		Map<String, String> redisLikeData = jedis.hgetAll(LikeEnum.KEY_LIKE_COUNT);

		//预设两个DAO,理论上每次都会用到两个DAO
		WritingDao<Article> aDao = DaoFactory.getArticleDao();
		WritingDao<Posts> pDao = DaoFactory.getPostsDao();

		//获取键值
		for (Map.Entry<String, String> vo : redisLikeData.entrySet()) {
			String likeRecordKey = vo.getKey();

			String[] splitKey = likeRecordKey.split("::");
			// 点赞的目标id
			Long id = Long.valueOf(splitKey[1]);
			// 缓存的点赞数
			int count = Integer.parseInt(vo.getValue());

			//判断点赞类型
			if (String.valueOf(TargetType.ARTICLE.code()).equals(splitKey[0])) {
				// 点赞了文章
				// 获取文章当前的点赞数
				int likeCount = aDao.getLikeCount(conn, id);
				// 获取最终点赞数
				int result = count + likeCount;
				// 更新点赞数
				aDao.updateLikeCount(conn, id, result);
			} else if (String.valueOf(TargetType.POSTS.code()).equals(splitKey[0])) {
				// 点赞了问贴
				// 获取问贴当前的点赞数
				int likeCount = pDao.getLikeCount(conn, id);
				// 获取最终点赞数
				int result = count + likeCount;
				// 更新点赞数
				pDao.updateLikeCount(conn, id, result);

			}
		}
		for (String key : redisLikeData.keySet()) {
			//储存数据成功后移出redis
			jedis.hdel(LikeEnum.KEY_LIKE_COUNT, key);
		}
		jedis.close();
	}

	/**
	 * 将redis的数据封装到实例中
	 * @param keys "targetType::userid::targetId"
	 * @return
	 */
	private LikeRecord getLikeRecord(String keys) {
		//切割获取数据
		String[] splitKey = keys.split("::");
		LikeRecord record = new LikeRecord();
		record.setTargetType(Integer.parseInt(splitKey[0]));
		record.setUserid(Long.valueOf(splitKey[1]));
		record.setTargetId(Long.valueOf(splitKey[2]));

		return record;
	}
}

策略模式

策略模式方便以后的扩展

Choose选择类

就是Context类,我改了个名字

/**
 * @author 寒洲
 * @description 点赞策略选择
 */
public class LikeStategyChoose {
	private LikeStrategy likeStrategy;

	public LikeStategyChoose(LikeStrategy likeStrategy){
		this.likeStrategy = likeStrategy;
	}

	/**
	 * 点赞相关操作
	 * @param userid 点赞的用户
	 * @param targetId 被点赞的目标
	 * @param likeType 被点赞的目标类型 文章/帖子/评论
	 */
	public void likeOperator(Long userid, Long targetId, TargetType likeType) {
		likeStrategy.likeOperate(userid, targetId, likeType);
	}
}
策略抽象类

除了指定子类的抽象方法likeOperate,此处还提供了两个工具方法,方便子类操作。

public abstract class LikeStrategy {

	protected Logger logger = Logger.getLogger(LikeStrategy.class);
	/**
	 * 点赞操作
	 * @param userid
	 * @param targetId
	 * @param likeType
	 */
	public abstract void likeOperate(Long userid, Long targetId, TargetType likeType);

	/**
	 * 获取redis缓存的点赞关系的域名
	 * @param userid
	 * @param targetId
	 * @param targetType
	 * @return 形如"targetType::userid::targetId"
	 */
	protected String getLikeFieldName(Long userid, Long targetId, int targetType) {
		String likeKey = targetType + "::" + userid + "::" + targetId;
		return likeKey;
	}

	/**
	 * 获取redis缓存的点赞数量的域名
	 * @param targetId
	 * @param targetType
	 * @return 形如"targetType::targetId"
	 */
	protected String getLikeFieldName(Long targetId, int targetType) {
		String likeKey = targetType + "::" + targetId;
		return likeKey;
	}
}

执行点赞类
/**
 * @author 寒洲
 * @description 点赞策略
 */
public class LikeStrategyImpl extends LikeStrategy {

	/**
	 * 点赞的redis value
	 */
	private static final String LIKE_STATE = "1";

	@Override
	public void likeOperate(Long userid, Long targetId, TargetType targetType) {
		/*
		以"targetType::userid::targetId"为redis的field,点赞状态为值
		点赞状态分为 1-已点赞 0-未点赞,可能未来会有踩,设为-1
		 */
		logger.trace("userid=" + userid + ", targetId=" + targetId + ", likeState=" + LIKE_STATE + ", targetType=" + targetType);
		//获取存入redis的域名fieldname
		//点赞关系的域名
		String likeRecordFieldName = getLikeFieldName(userid, targetId, targetType.code());
		//用于点赞数量统计的域名
		String likeCountFieldName = getLikeFieldName(targetId, targetType.code());

		Jedis jedis = JedisUtil.getJedis();
		// 获取用户点赞的数据,以userid和targetId为field,表为id
		String recordState = jedis.hget(LikeEnum.KEY_LIKE_RECORD, likeRecordFieldName);//缓存点赞关系
		if (LikeEnum.HAVE_LIKED.equals(recordState)) {
			// 已缓存点赞
			// 不做任何操作,未来可能有更新的操作
		} else {
			//未点赞或者无记录,修改记录。
			//之后在缓存数据持久化到数据库时会检查是否已点赞过
			logger.trace("未点赞或者无记录,修改缓存记录,暂不检查数据库");
			jedis.hset(LikeEnum.KEY_LIKE_RECORD, likeRecordFieldName, LIKE_STATE);

			/*
			更新缓存的点赞数量,点赞数+1
			如果没有记录,会添加记录,并执行hincrby操作
			 */
			jedis.hincrBy(LikeEnum.KEY_LIKE_COUNT, likeCountFieldName, 1L);
		}

		jedis.close();
	}
}

取消点赞类
/**
 * @author 寒洲
 * @description 取消点赞策略
 */
public class CancelLikeStrategyImpl extends LikeStrategy {

	/**
	 * 取消点赞的redis value
	 */
	private static final String UNLIKE_STATE = "0";

	@Override
	public void likeOperate(Long userid, Long targetId, TargetType targetType) {
		//点赞关系的域名
		String likeRecordFieldKey = getLikeFieldName(userid, targetId, targetType.code());
		//用于点赞数量统计的域名
		String likeCountFieldKey = getLikeFieldName(targetId, targetType.code());

		Jedis jedis = JedisUtil.getJedis();

		// 获取用户点赞的数据,以userid和targetId为key,表为id
		String likeRecordState = jedis.hget(LikeEnum.KEY_LIKE_RECORD, likeRecordFieldKey);

		if (LikeEnum.HAVE_LIKED.equals(likeRecordState)) {
			//已点赞,取消点赞
			logger.info("已点赞,取消点赞");
			//将value设为0,这样子就记录了取消点赞的状态,可以持久化到数据库
			jedis.hset(LikeEnum.KEY_LIKE_RECORD, likeRecordFieldKey, UNLIKE_STATE);

			/*
			更新缓存的点赞数量,点赞数+1
			如果没有记录,会添加记录,并执行hincrby操作
			 */
			jedis.hincrBy(LikeEnum.KEY_LIKE_COUNT, likeCountFieldKey, -1L);
		} else {
			//TODO 未点赞或者无记录,无操作
		}

		jedis.close();
	}


}

定时任务实现持久化

定时任务我同样采用了策略模式,此处只提供主要的代码,免得太乱了

定时任务类
public class LikePersistencebyMinutes {

	/** 单元时间单位 */
	private static final TimeUnit TIME_UNIT = TimeUnit.MINUTES;
	/** 首次执行的延时时间 */
	private static final long INITIAL_DELAY = 5;
	/** 定时执行的延迟时间 */
	private static final long PERIOD = 5;

	/**
	 * 定时任务
	 */
	private static ScheduledThreadPoolExecutor scheduled;

	/** 启动定时任务 */
	public static void runScheduled() {
		//创建线程池
		scheduled = new ScheduledThreadPoolExecutor(
				8, new NamedThreadFactory("点赞数据持久化"));
		// 第二个参数为首次执行的延时时间,第三个参数为定时执行的延迟时间
		scheduled.scheduleWithFixedDelay(new LikeRunnable(), INITIAL_DELAY, PERIOD, TIME_UNIT);
	}

	/**
	 * 关闭定时任务
	 * @throws Exception
	 */
	public static void shutDownScheduled() throws Exception {
		if (scheduled != null) {
			scheduled.shutdown();
		} else {
			throw new Exception("scheduled对象未创建!");
		}
	}
}
Runcable子类
public class LikeRunnable implements Runnable{

	@Override
	public void run() {
		logger.trace("[" + Thread.currentThread().getName() + "]线程运行(run),redis持久化!");
		LikeService service = ServiceFactory.getLikeService();
		try {
			// 了解 消息队列
			// 点赞是持久化待优化:获取记录时统计点赞数,并将关系储存在数据库,之后根据统计数更新字段
			service.persistLikeCount();
			service.persistLikeRecord();
		} catch (Exception e) {
			logger.error("[" + Thread.currentThread().getName() + "]线程 redis持久化异常!");
			e.printStackTrace();
		}
	}
}

参考

  1. 菜鸟教程-策略模式
  2. CSDN-设计模式-策略模式
  3. 通用点赞设计与实现
  4. 有关点赞缓存:点赞模块的设计及优化
  5. CSDN上关于点赞数据库表设计的讨论

最后

这是我第一次实际使用redis和jedis,也是第一次设计点赞功能,如果有不足之处请不吝赐教,我一点会虚心接受的!希望这篇文章对你有所帮助,有疑问请在评论区指出。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐