目录
一、你会怎么设计一个点赞功能?
1.1、点赞实现思路
1.2、点赞功能设计
1.2.1、MySQL 单表
1.2.2、单表 + MySQL 关联表
1.2.3、MySQL 关联表 + mq
1.2.4、redis + mq
1.2.5、mongodb 关联文档
二、性能测试
2.1、前置说明
2.2、10 万数据准备
一、你会怎么设计一个点赞功能?
1.1、点赞实现思路
我们先来想一想一个基本的点赞功能都需要哪些服务(这里以小红书系统为例):
读操作:当用户刷到一个专辑的时候,需要做以下几个操作
- 去查询当前用户是否有点赞.
- 查询当前点赞的数量.
写操作:当用户点击点赞按钮时候,需要进行以下几个操作
- 查询当前用户是否已经点赞.
- 如果点赞,就删除点赞信息,如果没有点赞,就添加点赞信息.
- 更新点赞数量(根据不同设计,可能会有这一步).
- 发布点赞消息.
Ps:之后的代码由于篇幅原因,没有任何封装
1.2、点赞功能设计
1.2.1、MySQL 单表
只设计一张 点赞信息表 来实现点赞功能.
表中记录了哪个用户对哪篇专辑进行点赞.
读操作:
public AlbumStatVO MySQLOne(
@RequestParam @NotBlank String albumId
) {
//为了简单,只设计了点赞表,因此这里通过四次查询点赞表来模拟
Long pageView = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getTargetId, albumId)
.count();
Long likeCnt = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getTargetId, albumId)
.count();
Long collectCnt = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getTargetId, albumId)
.count();
Long commentCnt = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getTargetId, albumId)
.count();
if(pageView == null || likeCnt == null || commentCnt == null || collectCnt == null) {
return null;
}
return AlbumStatVO.builder()
.albumId(Long.valueOf(albumId))
.pageView(pageView)
.likeCnt(likeCnt)
.collectCnt(collectCnt)
.commentCnt(commentCnt)
.build();
}
写操作:
public String MySQLOne(@RequestBody @Valid ActDTO dto) {
synchronized (locker1) {
boolean exists = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getPostId, dto.getPostId())
.eq(AlbumLike::getTargetId, dto.getTargetId())
.exists();
if(!exists) {
Db.save(
AlbumLike.builder()
.postId(Long.valueOf(dto.getPostId()))
.targetId(Long.valueOf(dto.getTargetId()))
.ctTime(new Date())
.utTime(new Date())
.build()
);
} else {
Db.lambdaUpdate(AlbumLike.class)
.eq(AlbumLike::getPostId, dto.getPostId())
.eq(AlbumLike::getTargetId, dto.getTargetId())
.remove();
}
}
return "ok";
}
好处:
实现起来简单:一张表不仅记录了当前专辑有谁点赞,还可以通过 count(*) 查询出当前专辑的点赞量.
缺点:
速度低:如果这样设计点赞表,那么你肯定也会设计出差不多的 收藏表、评论表...(如果需要,可能还有记录访问量的表). 那么当用户看到这个专辑的时候,光是查看点赞量、访问量... 都需要至少 3 次 count(*) 操作(频繁的和数据库建立连接和断开连接都有一定的开销).
1.2.2、单表 + MySQL 关联表
为了解决刚刚提到的问题,可以再设计一个关联表——专辑信息统计表
这个表中就记录了访问量、点赞量、收藏量... 这些信息. 同时使用 专辑id 保证和 专辑表的关联关系.
读操作:
public AlbumStatVO MySQLTwo(
@RequestParam @NotBlank String albumId
) {
AlbumStat stat = Db.lambdaQuery(AlbumStat.class)
.eq(AlbumStat::getAlbumId, albumId)
.one();
if(stat == null) {
return null;
}
return AlbumStatVO.builder()
.albumId(Long.valueOf(albumId))
.pageView(stat.getPageView())
.likeCnt(stat.getLikeCnt())
.collectCnt(stat.getCollectCnt())
.commentCnt(stat.getCommentCnt())
.build();
}
写操作:
public String MySQLTwo(@RequestBody @Valid ActDTO dto) {
synchronized (locker2) {
boolean exists = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getPostId, dto.getPostId())
.eq(AlbumLike::getTargetId, dto.getTargetId())
.exists();
if(!exists) {
Db.save(
AlbumLike.builder()
.postId(Long.valueOf(dto.getPostId()))
.targetId(Long.valueOf(dto.getTargetId()))
.ctTime(new Date())
.utTime(new Date())
.build()
);
Db.lambdaUpdate(AlbumStat.class)
.setSql("like_cnt = like_cnt + 1")
.eq(AlbumStat::getAlbumId, dto.getTargetId())
.update();
} else {
Db.lambdaUpdate(AlbumLike.class)
.eq(AlbumLike::getPostId, dto.getPostId())
.eq(AlbumLike::getTargetId, dto.getTargetId())
.remove();
Db.lambdaUpdate(AlbumStat.class)
.setSql("like_cnt = like_cnt - 1")
.eq(AlbumStat::getAlbumId, dto.getTargetId())
.update();
}
}
return "ok";
}
好处:
读操作方便,效率相对较高:解决了频繁和数据库建立连接和断开连接的问题. 查询点赞量、收藏量...这些信息,只需要通过 专辑id 对 专辑信息统计表 进行一次查询就可以得到所有的统计信息.
缺点:
写操作麻烦,效率相对较低:每次保存了用户点赞信息,还需要更新 专辑信息统计表 中的点赞量信息.
在一致性的问题上,引入了额外开销和系统复杂度.
1.2.3、MySQL 关联表 + mq
那么此时遇到的问题就是,点赞效率低,并且 点赞的统计 和 点赞消息添加 耦合在了一起.
那么此时使用 mq 就能很好的解决上述问题,因为点赞量这种数据并不需要很强的时效性,只需要保证最终一致性即可.
读操作和 MySQL 关联表没有区别.
写操作:
public String MqOne(@RequestBody @Valid ActDTO dto) {
long postId = Long.parseLong(dto.getPostId());
long targetId = Long.parseLong(dto.getTargetId());
synchronized (locker3) {
boolean exists = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getPostId, postId)
.eq(AlbumLike::getTargetId, targetId)
.exists();
if(!exists) {
Db.save(
AlbumLike.builder()
.postId(postId)
.targetId(targetId)
.ctTime(new Date())
.utTime(new Date())
.build()
);
} else {
Db.lambdaUpdate(AlbumLike.class)
.eq(AlbumLike::getPostId, postId)
.eq(AlbumLike::getTargetId, targetId)
.remove();
}
rabbitTemplate.convertAndSend(
MqConst.STAT_DIRECT,
MqConst.LIKE_MySQL_QUEUE,
objectMapper.writeValueAsString(
LikeMsg.builder()
.targetId(targetId)
.isLike(!exists)
.build()
)
);
}
return "ok";
}
好处:
- 效率相对较高,达到了削峰填谷的作用,避免大量请求同一时间打入数据库导致崩溃
- 实现了点赞量统计和点赞消息的解耦合.
缺点:
需要引入额外的表来统计数据.
单表的读写性能在大量数据的情况下,还是会达到瓶颈.
为了保证一致性,增加系统复杂度.
1.2.4、redis + mq
Ps:此处先不考虑 雪崩、击穿、穿透 问题.
这里我只考虑使用 redis 作为缓存,不考虑作为内存数据库的情况.
原因:1. 贵 2. Redis 实例突然崩溃或遭遇其他故障,RDB和AOF机制可能无法完全保证数据的完整性,这里为了保证数据的强一致性,还需要 mysql.
mq 用来解决数据同步问题.
实现思路:
a)redis 缓存更新策略:在 redis 的配置文件中设置 maxmemory (内存使用上限). 读的时候先从 redis 中读数据,如果没有读到数据,就从 mysql 中读取数据,然后再将数据通过 mq 同步到 redis 上. 经过一段时间的 “动态平衡”, redis 中的剩余数据就是 “热点数据” 了.
redis 实现点赞功能有很多种方式,这里我讲保证强一致性的方案.
b)具体实现:点赞功能使用 set 类型是非常合适的,这样不仅表示了当前专辑有哪些用户进行点赞,还可以通过 set 的 scard 获取点赞量.
读操作:
- 先查 redis 上有没有点赞信息.
- redis 中存在:直接返回
- redis 中不存在:去 MySQL 查数据,同步到 redis 上.
public AlbumStatVO redisOne(
@RequestParam @NotBlank String albumId
) {
//1.redis 是否有
List<Object> statList = redisTemplate.executePipelined(new SessionCallback<String>() {
@Override
public <K, V> String execute(RedisOperations<K, V> operations) throws DataAccessException {
RedisOperations<String, Object> template = (RedisOperations<String, Object>) operations;
template.opsForValue().get(RedisKeyConst.ALBUM_PAGE_VIEW + albumId);
template.opsForSet().size(RedisKeyConst.ALBUM_LIKE + albumId);
template.opsForValue().get(RedisKeyConst.ALBUM_COLLECT + albumId);
template.opsForValue().get(RedisKeyConst.ALBUM_COMMENT + albumId);
return null;
}
});
if(statList.get(0) != null && statList.get(1) != null
&& statList.get(2) != null && statList.get(3) != null) {
return AlbumStatVO.builder()
.albumId(Long.valueOf(albumId))
.pageView((Long) statList.get(0))
.likeCnt((Long) statList.get(1))
.collectCnt((Long) statList.get(2))
.commentCnt((Long) statList.get(3))
.build();
}
//2.redis上没有,去查数据库
AlbumStat dbStat = Db.lambdaQuery(AlbumStat.class)
.eq(AlbumStat::getAlbumId, albumId)
.one();
if(dbStat == null) {
return null;
}
List<Long> userIds = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getTargetId, albumId)
.list().stream()
.map(AlbumLike::getPostId)
.toList();
//3.将数据保存到 redis 上
redisTemplate.executePipelined(new SessionCallback<Object>() {
@Override
public <K, V> Object execute(RedisOperations<K, V> operations) throws DataAccessException {
RedisOperations<String, Object> temp = (RedisOperations<String, Object>) operations;
temp.opsForValue().set(RedisKeyConst.ALBUM_PAGE_VIEW + albumId, dbStat.getPageView());
temp.opsForSet().add(RedisKeyConst.ALBUM_LIKE + albumId, userIds.toArray()); //第二个参数是数组
temp.opsForValue().set(RedisKeyConst.ALBUM_COLLECT + albumId, dbStat.getCollectCnt());
temp.opsForValue().set(RedisKeyConst.ALBUM_COMMENT + albumId, dbStat.getCommentCnt());
return null;
}
});
return AlbumStatVO.builder()
.albumId(Long.valueOf(albumId))
.pageView(dbStat.getPageView())
.likeCnt(dbStat.getLikeCnt())
.commentCnt(dbStat.getCommentCnt())
.collectCnt(dbStat.getCollectCnt())
.build();
}
写操作:
- 首先去判断 redis 上判断是否有当前用户的点赞信息
- redis 中存在:删除 redis 上的点赞信息.
- redis 中不存在:又有两种情况
- 当前用户确实没有对此专辑进行过点赞.
- 点赞数据过期了.
- 无论是以上哪种情况,我为了保证强一致性,都会去 mysql 中查一下,点赞数据是否存在.(如果你不想保证强一致性,就是愿意只通过 redis 来判断,就是不怕某些特殊情况下 redis 突然崩溃,持久化文件损坏,你确实可以写个定期更新任务,定期同步 redis)
- mysql 中存在:redis 上啥都不用做,反正查到点赞数据存在,也是删除.
- mysql 中不存在:在 redis 上添加点赞数据.
- 根据上述所有情况,通过 mq 更新 mysql 中的 点赞量 和 点赞信息.
public String redisOne(@RequestBody @Valid ActDTO dto) {
synchronized (locker4) {
boolean isExists = Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(
RedisKeyConst.ALBUM_LIKE + dto.getTargetId(),
dto.getPostId())
);
Boolean isLike = null;
if(isExists) {
redisTemplate.opsForSet().remove(
RedisKeyConst.ALBUM_LIKE + dto.getTargetId(),
dto.getPostId()
);
isLike = false;
} else {
isExists = Db.lambdaQuery(AlbumLike.class)
.eq(AlbumLike::getPostId, dto.getPostId())
.eq(AlbumLike::getTargetId, dto.getTargetId())
.exists();
if(!isExists) {
redisTemplate.opsForSet().add(
RedisKeyConst.ALBUM_LIKE + dto.getTargetId(),
dto.getPostId()
);
isLike = true;
} else {
isLike = false;
}
}
rabbitTemplate.convertAndSend(
MqConst.STAT_DIRECT,
MqConst.SYNC_LIKE_MYSQL_QUEUE,
objectMapper.writeValueAsString(
LikeMsg.builder()
.isLike(isLike)
.postId(Long.valueOf(dto.getPostId()))
.targetId(Long.valueOf(dto.getTargetId()))
.build()
)
);
}
return "ok";
}
好处:
redis 是在内存中操作数据,速度大大提升.
坏处:
- 使用 redis 真的一定快么?某些情况不一定快,因为 redis 作为客户端服务器系统,也会存在一定的网络开销.
- 贵!内存资源是十分珍贵的,不适合用来存储大量数据.
- 大大增加了系统复杂度,需要考虑数据一致性,时效性问题.
1.2.5、mongodb 关联文档
MongoDB 使用内存映射技术,将数据暂时存储在内存中,而不是直接持久化到存储设备中。由于内存读取速度远快于磁盘,因此 MongoDB 在 IO 操作上相比于 MySQL 更加高效.
MongoDB 相比于 MySQL 主要的缺点就是事务支持相对较弱,但是对于点赞这种数据,也不需要太强的事务支持,因此使用 MongoDB 来存储点赞数据是非常合适的.
读操作:
public AlbumStatVO mongoOne(
@RequestParam String albumId
) {
AlbumStatGO statGO = mongoTemplate.findOne(
Query.query(
Criteria
.where("_id")
.is(Long.valueOf(albumId)))
, AlbumStatGO.class);
if(statGO == null) {
return null;
}
return AlbumStatVO.builder()
.albumId(Long.valueOf(albumId))
.pageView(statGO.getPageView())
.likeCnt(statGO.getLikeCnt())
.commentCnt(statGO.getCommentCnt())
.collectCnt(statGO.getCollectCnt())
.build();
}
写操作:
public String mongoOne(@RequestBody @Valid ActDTO dto) {
long postId = Long.parseLong(dto.getPostId());
long targetId = Long.parseLong(dto.getTargetId());
synchronized (locker5) {
boolean exists = mongoTemplate.exists(
Query.query(
Criteria
.where("post_id")
.is(postId)
.and("target_id")
.is(targetId)
),
AlbumLikeGO.class
);
if(!exists) {
mongoTemplate.insert(
AlbumLikeGO.builder()
.postId(postId)
.targetId(targetId)
.ctTime(new Date())
.utTime(new Date())
.build()
);
mongoTemplate.updateFirst(
Query.query(
Criteria
.where("_id")
.is(targetId)
),
new Update().inc("like_cnt", -1),
AlbumStatGO.class
);
} else {
mongoTemplate.remove(
Query.query(
Criteria
.where("post_id")
.is(postId)
.and("target_id")
.is(targetId)
),
AlbumLikeGO.class
);
mongoTemplate.updateFirst(
Query.query(
Criteria
.where("_id")
.is(targetId)
),
new Update().inc("like_cnt", -1),
AlbumStatGO.class
);
}
}
return "ok";
}
二、性能测试
2.1、前置说明
a)业务说明
由于我们一般不会只查询点赞数量,而是后端将点赞量、评论量、收藏量一并返回,因此这里在读操作上为了实际场景,我们会将这些数据一并返回.
写操作上,只关注点赞功能即可.
b)服务器配置
4C 6G
部署了 redis、mongo、mysql、rabbitmq......
当前项目也已部署.
c)测试环境
并发用户量:100
持续时间:2min
爬坡:1min
2.2、10 万数据准备
为了减少网络通讯次数,这里使用批处理,每次添加 1000 条数据.
a)mysql 数据准备
@SpringBootTest
@Slf4j
class MySQLDataTests {
private static final int albumNum = 1000;
private static final int userNum = 100;
private static int count = 0;
public void addLike() {
for(int userId = 1; userId <= userNum; userId++) {
//批量添加
List<AlbumLike> list = new ArrayList<>();
for (int albumId = 1; albumId <= albumNum; albumId++) {
list.add(
AlbumLike.builder()
.postId((long) userId)
.targetId((long) albumId)
.ctTime(new Date())
.utTime(new Date())
.build()
);
log.info("插入 {} 条", ++count);
}
Db.saveBatch(list);
}
}
public void addStat() {
for(int albumId = 1; albumId <= albumNum; albumId++) {
Db.save(
AlbumStat.builder()
.albumId((long) albumId)
.likeCnt((long) userNum)
.build()
);
log.info("当前执行 {} 条", albumId);
}
}
@Test
public void run() throws InterruptedException {
addLike();
addStat();
}
}
执行时间是 1min 12s
b)mongo 数据准备
@Slf4j
@SpringBootTest
public class MongoDataTests {
@Autowired
private MongoTemplate mongoTemplate;
private static final int albumNum = 1000;
private static final int userNum = 100;
private static int count = 0;
private void addLikeData() {
//分批次插入
for(int userId = 1; userId <= userNum; userId++) {
List<AlbumLikeGO> list = new ArrayList<>();
for(int albumId = 1; albumId <= albumNum; albumId++) {
list.add(
AlbumLikeGO.builder()
.postId((long) userId)
.targetId((long) albumId)
.ctTime(new Date())
.utTime(new Date())
.build()
);
log.info("插入 {} 条", ++count);
}
mongoTemplate.insertAll(list);
}
}
private void addStat() {
for(int albumId = 1; albumId <= albumNum; albumId++) {
mongoTemplate.insert(
AlbumStatGO.builder()
.albumId((long) albumId)
.pageView(0L)
.likeCnt((long) userNum)
.collectCnt(0L)
.commentCnt(0L)
.build()
);
}
}
@Test
public void run() throws InterruptedException {
addLikeData();
addStat();
}
}
执行时间大约是 3s (和 mysql 恐怖的差距)
2.3、实际结果
a)读操作
b)写操作
c)结论:除了单表操作比较耗时外,对于中小型项目而言,频繁的读写操作场景,使用 mongo 就够用了. 甚至都不用上 mq,更甚至有的场景下 redis 性能还不如 mongo.
对于中小型项目,能有那些项目达到每秒钟同时有 100 个用户连续请求 1 ~ 3 次接口,持续 2min 不停???
服务器能崩???平均 11ms 的响应时间你等不了???
怕是你的服务器不行吧~
为什么我这么说,我这还有一个 2C 2G 的服务器(应用部署,环境在 4C 6G 的服务器上),来给你看看效果
同样是 100用户并发,持续 2min,爬坡 1min
a)读操作
b)写操作
很多请求甚至还没有来得及处理,就崩了~
Ps:本文禁止转载!!!不然 si 妈!!!