博客系统点赞功能 使用策略模式及redis缓存和持久化

博客系统点赞功能 使用策略模式及redis缓存和持久化

场景概述

在实际开发中,点赞是高频操作,如果每一次点赞或者获取点赞数都要查询数据库,将会给数据库造成极大的压力,因此尝试用缓存技术来缓存操作。常用的有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,因此我们使用likeRecord和likeCount区分上述两种缓存。

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,包括likeRecord和likeCount。之后通过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 redisLikeData = jedis.hgetAll(LikeEnum.KEY_LIKE_RECORD);

//实例化三个点赞DAO

createDaoInstance();

//获取键值

for (Map.Entry 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 redisLikeData = jedis.hgetAll(LikeEnum.KEY_LIKE_COUNT);

//预设两个DAO,理论上每次都会用到两个DAO

WritingDao

aDao = DaoFactory.getArticleDao();

WritingDao pDao = DaoFactory.getPostsDao();

//获取键值

for (Map.Entry 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();

}

}

}

参考

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

最后

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

相关推荐