场景概述
在实际开发中,点赞是高频操作,如果每一次点赞或者获取点赞数都要查询数据库,将会给数据库造成极大的压力,因此尝试用缓存技术来缓存操作。常用的有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<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();
}
}
}
参考
最后
这是我第一次实际使用redis和jedis,也是第一次设计点赞功能,如果有不足之处请不吝赐教,我一点会虚心接受的!希望这篇文章对你有所帮助,有疑问请在评论区指出。