通用点赞系统设计方案
近期接到一个点赞系统的需求,回想起以前也做过类似的需求,需求核心一致,但侧重点不同。借机梳理一下常见点赞系统的业务特点,并形成一个通用设计。 文中的 点赞 只是一种业务场景,此设计可扩展到 已读、收藏 等需要记录计数和部分元数据的场景,不过多叙述
业务场景
点赞常见的业务场景,大体上分为三类:
- 以数量展示为主的场景,用户对数量的变动非常敏感,对计数访问频繁,如知乎、B站、Quora等
- 对数量和点赞用户变更都比较敏感,有时间线需求,如微信朋友圈、微博等
- 对业务扩展性要求较高,但对系统并发度没什么要求
这三类业务场景有一些共同的业务特点:
- 对计数敏感,统计数量和是否点赞是高频访问场景
- 要求较高的可用性(响应速度)和一致性
- 对点赞用户敏感,如查询点赞历史,查询共同点赞用户等
- 对点赞时序敏感,如关注前百名点赞用户
- 可能会基于时间纬度进行数据查询,如查询一天内的点赞用户等
把这些业务场景转化为具体系统功能:
- 查询点赞数目
- 查询用户是否已点赞
- 点赞/取消点赞
- 查询点赞历史
这些系统功能转化为技术需求就是:
- 需要实现一个计数器
- 需要维护用户和点赞对象之间的关系
- 需要记录行为时间
接下来我们根据不同的业务场景和系统要求,进行技术方案设计。
方案一:基于优先队列
选型: Redis 基本设计
- 以点赞主体的唯一键建立ZSet
- 以点赞用户ID和类型作为ZSet元素
- 以点赞时间作为ZSet的Score
- 如果需要从用户维度查询点赞记录,以用户为主体建立ZSet
主要业务场景实现方案
- 点赞数
zcard
O(1) - 是否点赞
zsocre
O(1) - 点赞/取消点赞
zadd
/zrem
O(log(N)) - 查询点赞历史
zrangebyscore
O(log(N)+M)
设计优势
- 高可用(并发响应)、高一致性,可以较好支持ToC的场景
- 常见业务场景运算快速
- 易扩展。千万级别的数据量,Redis单机可以支撑,如果数据量增长,可以基于Cluster模式实现水平扩展。
设计缺陷
- 可能存在数据丢失,但在点赞这个业务场景下是可接受的
- 存在数据量过大的风险,需要对数据增长速度做好评估
方案二:基于关系型数据库
选型: Mysql 基本设计
- 保存点赞主体ID、用户ID、行为时间以及部分扩展性的元数据
- 以点赞主体的唯一键和用户ID建立唯一索引
- 如果需要以用户维度查询点赞记录,对用户ID建立索引
- 如果对行为时间敏感,可以对行为时间创建索引
主要业务场景实现方案
- 点赞数,利用点赞主体索引进行聚合查询 O(log(N)+M)
- 是否点赞,利用点赞主体和用户为索引进行查询 O(log(N))
- 点赞/取消点赞,利用点赞主体和用户为索引插入或删除数据 O(log(N))
- 查询点赞历史,利用点赞主体索引进行查询 O(log(N) *log(M))
设计优势
- 高一致性,不会因为数据库宕机造成数据丢失
- 更好的扩展性,磁盘空间充裕,可以支持更丰富的业务扩展设计
- 更低实现成本,不会因为数据量过快增长产生硬件焦虑
设计缺陷
- 可用性较低,应对并发度稍高的场景会存在性能问题
- 主要业务场景的实现复杂度比方案一更高,在 log(N) 到 M*log(N) 之间
- 水平扩展性较低,需要手动进行分库分表等设计以保证大数据量下的性能
方案三:基于缓存 + 消息队列 + 关系型数据库
选型: Redis + Kafka + Mysql/MongoDB/HBase 基本设计
- 基于Redis string型保存点赞数目,INCR原语保证点赞数目一致性
- 基于Redis bitmap设计是否点赞bloomfilter,取消点赞需要通过counting-filter实现
- 基于Kafka实现用户行为的快速持久化,每次点赞/取消点赞都保存为一条消息
- 通过消息消费,保存用户数据到关系型数据库
- 低频操作,如查询点赞历史等,通过查询关系型数据库实现
主要业务场景实现方案
- 点赞数
get
O(1) - 是否点赞
getbit
O(1) (结果为真的情况下需要查询数据库,但整体概率较低) - 点赞/取消点赞
incr
/decr
+setbit
+ 写入消息队列 O(1) - 查询点赞历史,利用点赞主体索引进行查询,复杂度 O(log(N) *log(M))
设计优势
- 兼具方案1和方案2的优点
- 消息队列弥补缓存持久性不足的缺点,同时减小了缓存与数据库文件系统间的写入效率差异
- 高可用性(并发响应)、高一致性,可以很好支持大部分ToC的业务场景
- 主要业务场景运算快速,可用性远胜于方案2
- 更低实现成本,对内存要求远低于方案1
- 整体扩展性更好
- 主要业务场景水平扩展性与方案1持平
- 低频业务场景可以选择写多读少的数据存储方案,水平扩展能力优于方案2
- 可以保存更多元数据,利于业务逻辑扩展
设计缺陷
- 实现较为复杂,对于多数业务场景来说存在过度设计的问题
- 业务高峰可能因为消息消费不及时带来部分数据延迟,如无法查询到点赞记录
部分实现代码
// 点赞
public boolean upvote(Upvote upvote) {
// 增加主题计数
redisTemplate.opsForValue().increment("demo.community:upvote:count" + upvote.getTopicId());
// 计算BloomFilter偏移量
int[] offsets = userHash(upvote.getUserId());
for (long offset : offsets) {
redisTemplate.opsForValue().setBit("demo.community:upvote:user:filter" + upvote.getTopicId(), offset, true);
}
// 异步发送消息
kafkaTemplate.send("demo-community-vote", gson.toJson(upvote));
return true;
}
// 是否点赞
public boolean upVoted(Upvote upvote) {
// 计算BloomFilter偏移量
int[] offsets = userHash(upvote.getUserId());
for (long offset : offsets) {
if (!Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(UPVOTE_USER_FILTER_PREFIX + upvote.getTopicId(), offset))) {
// 不存在对应点赞数据,直接返回
return false;
}
}
// 不能确定点赞记录是否存在,查询数据库
return upvoteMysqlDAO.findOne(Example.of(upvote)).isPresent();
}
性能测试
硬件情况:
- cpu intel i5 2.7GHz 4核8线程
- memory 16G 系统和用户线程消耗 7G左右
受限于以下问题影响,测试结果仅供参考:
- 程序逻辑随机性不足,难以实际模拟真实生产数据场景
- 硬件限制,业务服务和数据库在同一个设备上,会形成资源竞争
- 实测过程中,发现用户服务对测试程序影响较大,同样的程序在不同时间执行也会存在差异
测试场景
主要测试以下四个场景:
- 1千万数据顺序写入数据库
- 1千万数据并发写入数据库
- 并发请求点赞数目1千万次
- 并发请求是否点赞1千万次
1千万数据顺序写入逻辑:
@Test
public void insert() {
long topicId = 10_000_000L;
long start = System.currentTimeMillis();
for (long userId = 1; userId < 10_000_000; userId++) {
Upvote upvote = Upvote.builder()
.topicId(topicId)
.userId(userId)
.votedAt(LocalDateTime.now())
.build();
upvoteService.upvote(upvote);
if (userId % 10_000 == 0) {
System.out.println("insert data count:" + userId);
long stop = System.currentTimeMillis();
System.out.println("time cost seconds:" + (stop - start) / 1000);
}
}
long stop = System.currentTimeMillis();
System.out.println("total time cost seconds:" + (stop - start) / 1000);
}
1千万数据并发写入逻辑:
@Test
public void insert() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(100);
long start = System.currentTimeMillis();
AtomicInteger count = new AtomicInteger();
// 将用户分为100组,每组由1个线程顺序往10000个主题中写入数据
for (int taskId = 0; taskId < 100; taskId++) {
final int startUserId = taskId * 10 + 1;
executor.submit(() -> {
Upvote upvote = new Upvote();
upvote.setVotedAt(LocalDateTime.now());
for (long userId = startUserId; userId < startUserId + 10; userId++) {
for (long topicId = 1; topicId <= 10_000; topicId++) {
upvote.setTopicId(topicId);
upvote.setUserId(userId);
upvoteService.upvote(upvote);
count.incrementAndGet();
}
System.out.println("insert data count:" + count.get());
long stop = System.currentTimeMillis();
System.out.println("time cost seconds:" + (stop - start) / 1000);
}
});
}
// 等待任务完成,每轮检查一次任务完成情况,已完成则提前结束等待,最多等待30轮
executor.shutdown();
int waitRound = 0;
while (!executor.awaitTermination(1, TimeUnit.MINUTES) && ++waitRound < 30) {
System.out.println("wait round: " + waitRound);
}
System.out.println("insert data count:" + count.get());
long stop = System.currentTimeMillis();
System.out.println("total time cost seconds: " + (stop - start) / 1000);
}
1千万个请求并发查询点赞数
@Test
public void testCount() throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(100);
long start = System.currentTimeMillis();
AtomicInteger count = new AtomicInteger();
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
for (long topicId = 1; topicId <= 10_000; topicId++) {
upvoteService.count(topicId);
int total = count.incrementAndGet();
if (total % 10000 == 0) {
long stop = System.currentTimeMillis();
System.out.println("select data count:" + total + " time cost seconds:" + (stop - start) / 1000);
}
}
});
}
executor.shutdown();
int waitRound = 0;
while (!executor.awaitTermination(1, TimeUnit.MINUTES) && ++waitRound < 30) {
System.out.println("wait round: " + waitRound);
}
long stop = System.currentTimeMillis();
System.out.println("total time cost seconds: " + (stop - start) / 1000);
}
1千万个请求并发查询是否点赞
@Test
public void testVoted() throws InterruptedException {
ExecutorService executor = Executors.newFixedThreadPool(100);
long start = System.currentTimeMillis();
AtomicInteger count = new AtomicInteger();
for (int taskId = 0; taskId < 100; taskId++) {
final int startUserId = 20000 + taskId * 10 + 1;
executor.submit(() -> {
for (long userId = startUserId; userId < startUserId + 10; userId++) {
for (long topicId = 1; topicId <= 10_000; topicId++) {
Upvote upvote = Upvote.builder()
.topicId(topicId)
.userId(userId)
.build();
upvoteService.upVoted(upvote);
int total = count.incrementAndGet();
if (total % 10000 == 0) {
long stop = System.currentTimeMillis();
System.out.println("select data count:" + total + " time cost seconds:" + (stop - start) / 1000);
}
}
}
});
}
executor.shutdown();
int waitRound = 0;
while (!executor.awaitTermination(1, TimeUnit.MINUTES) && ++waitRound < 30) {
System.out.println("wait round: " + waitRound);
}
long stop = System.currentTimeMillis();
System.out.println("total time cost seconds: " + (stop - start) / 1000);
}
测试结果
所有场景测试结果(qps)如下表:
测试场景 | 方案一 | 方案二 | 方案三 |
---|---|---|---|
顺序写入 | 7000 | 700 | 2200 |
并发写入 | 27700 | 2100 | 5600 |
点赞数 | 31600 | 1500 | 33800 |
是否点赞 | 28500 | 1400 | 13400 |
此处的qps指每秒完成的业务单元运算次数,不是指数据库层面的一个原子操作
Redis 测试情况
元素为8字节ID,socre为8字节时间戳,加上前驱后继指针共32字节,索引节点3个指针需要24字节 ZSet为跳表,检索1千万个元素需要索引节点数为 10_000_000 + 10_000_000/2 + 10_000_000/4 + ... + 1 ~= 20_000_000 预计1千万条数据的内存消耗 32B 1_000_000 + 24B 20_000_000 ~= 0.8GB
顺序写入:1千万个元素插入单个ZSet内,内存消耗1.16G,耗时1488S(单线程),qps~=7000 并发写入:1千万个元素分布到1万个ZSet内,内存消耗1.06G,耗时361S(100线程),qps~=27700 查询点赞数:100个线程并发查询1千万数据,耗时316S,qps~=31600 查询是否点赞:100个线程并发查询1千万数据,耗时351S,qps~=28500
Note: 由于Redis是基于内存的高速写入操作,实测过程中并发度到10左右qps即可达到程序瓶颈,并发度提升到100并不能提升整体吞吐量,反而会降低单个任务的处理时间
Mysql 测试情况
数据:主键ID-8字节,主题ID-8字节,用户ID-8字节,行为时间-8字节,每条数据32字节,索引24字节,总计56字节
顺序写入:1千万条数据单线程插入,执行了300万条数据,耗时4500秒,qps~=667 并发写入:1千万条数据100个线程并发插入,30分钟插入380万数据,qps~=2100 查询点赞数:查询60万数据,耗时391S,qps~=1500 查询是否点赞:查询50万数据,耗时366S,qps~=1400
Note: Mysql的写入效率远低于Redis,实测过程中需要达到50左右的并发度,才能逐渐触及瓶颈
通过Mysqladmin查看到,数据库qps约8000
mysqladmin -uroot status
Uptime: 1517 Threads: 122 Questions: 12211865 Slow queries: 0 Opens: 246 Flush tables: 3 Open tables: 167 Queries per second avg: 8050.009
数据库的统计数据和实际运算结果呈现出来4倍差距,这个问题后续再分析。
Redis + Kafka + Mysql 测试情况
1千万个元素,按1%的误报率构建BloomFilter,需要三个数位保存,意味着要向Redis写入三次 1万个计数器,Redis内存基本没什么消耗
顺序写入:1千万条数据单线程写入,30分钟完成400万数据,qps~=2200 并发写入:1千万条数据并发写入,耗时1760S,qps~=5700 查询点赞数:100个线程并发查询1千万数据,耗时296S,qps~=33800 查询是否点赞:100个线程并发查询6百万数据,耗时446S,qps~=13400
Note: 对比方案一,本方案需要利用Redis存储BloomFilter,写入场景的频率增加了3倍,导致整体的吞吐量下降。从单机测试结果分析,写入瓶颈不在Kafka,而在于对布隆过滤器的多次位写入操作。如果减少布隆过滤器的写入次数,可以提升整体的吞吐效率。
通过Kafka-producer-perf-test查看写入性能,可以看到写入Kafka不是瓶颈
kafka-producer-perf-test --topic demo-community-vote --throughput -1 --num-records 100000 --record-size 1024 --producer-props bootstrap.servers=127.0.0.1:9092
100000 records sent, 52410.901468 records/sec (51.18 MB/sec), 291.51 ms avg latency, 667.00 ms max latency, 291 ms 50th, 369 ms 95th, 398 ms 99th, 428 ms 99.9th.
接口测试
使用 wrk
进行对三个常见业务场景进行性能测试:
- 随机写入点赞数据
- 查询点赞数量
- 查询是否点赞
由于硬件水平限制,并发超过2000时会触及Web服务器瓶颈,因此每个业务场景仅进行100并发和1000并发两次测试。
整体测试结果
场景 | 并发度 | 指标 | 方案一 | 方案二 | 方案三 |
---|---|---|---|---|---|
写入点赞 | 100 | p99 | 167ms | 206ms | 100ms |
qps | 1800 | 1300 | 1600 | ||
1000 | p99 | 254ms | 3000ms | 900ms | |
qps | 7100 | 700 | 1700 | ||
点赞数量 | 100 | p99 | 47ms | 716ms | 55ms |
qps | 4900 | 700 | 6000 | ||
1000 | p99 | 182ms | 2780ms | 280ms | |
qps | 8500 | 500 | 6900 | ||
是否点赞 | 100 | p99 | 36ms | 500ms | 70ms |
qps | 5100 | 700 | 5200 | ||
1000 | p99 | 488ms | 2780ms | 211ms | |
qps | 4200 | 600 | 7900 |
Redis 测试情况
随机写入点赞数据
Running 15s test @ http://127.0.0.1:8081/upvote/random
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 55.60ms 31.99ms 302.77ms 83.58%
Req/Sec 227.21 100.30 475.00 55.26%
Latency Distribution
50% 43.53ms
75% 65.46ms
90% 100.94ms
99% 167.34ms
27081 requests in 15.09s, 3.31MB read
Requests/sec: 1794.27
Transfer/sec: 224.53KB
Running 15s test @ http://127.0.0.1:8081/upvote/random
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 135.43ms 34.15ms 326.33ms 79.71%
Req/Sec 0.91k 290.23 1.89k 73.70%
Latency Distribution
50% 131.31ms
75% 146.71ms
90% 171.76ms
99% 254.73ms
107072 requests in 15.09s, 13.09MB read
Socket errors: connect 0, read 3491, write 24, timeout 0
Requests/sec: 7097.73
Transfer/sec: 0.87MB
随机查询点赞数量
Running 15s test @ http://127.0.0.1:8081/upvote/random/count
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 19.70ms 8.03ms 79.67ms 75.09%
Req/Sec 617.23 186.98 1.16k 65.80%
Latency Distribution
50% 18.89ms
75% 23.20ms
90% 28.27ms
99% 46.94ms
74139 requests in 15.10s, 9.06MB read
Requests/sec: 4910.58
Transfer/sec: 614.66KB
Running 15s test @ http://127.0.0.1:8081/upvote/random/count
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 109.59ms 25.53ms 304.32ms 70.98%
Req/Sec 1.09k 346.52 2.06k 77.26%
Latency Distribution
50% 107.17ms
75% 120.24ms
90% 141.07ms
99% 182.09ms
128967 requests in 15.09s, 15.76MB read
Socket errors: connect 0, read 6815, write 49, timeout 0
Requests/sec: 8544.67
Transfer/sec: 1.04MB
随机查询是否点赞
Running 15s test @ http://127.0.0.1:8081/upvote/random/voted
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 18.84ms 4.77ms 69.08ms 81.61%
Req/Sec 641.78 102.37 0.91k 70.50%
Latency Distribution
50% 17.91ms
75% 20.58ms
90% 24.04ms
99% 36.23ms
76797 requests in 15.04s, 9.46MB read
Requests/sec: 5104.72
Transfer/sec: 644.00KB
Running 15s test @ http://127.0.0.1:8081/upvote/random/voted
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 240.45ms 69.55ms 588.15ms 75.02%
Req/Sec 516.43 231.52 1.15k 66.13%
Latency Distribution
50% 245.73ms
75% 275.92ms
90% 306.72ms
99% 488.76ms
60775 requests in 15.08s, 7.48MB read
Socket errors: connect 0, read 3585, write 102, timeout 0
Requests/sec: 4029.91
Transfer/sec: 507.67KB
Mysql 测试情况
单机并发在200左右还可以接受,到500并发时平均耗时直线上升
随机写入点赞数据
Running 15s test @ http://127.0.0.1:8081/upvote/random
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 74.73ms 39.44ms 358.83ms 75.32%
Req/Sec 165.73 43.15 313.00 68.72%
Latency Distribution
50% 65.42ms
75% 92.70ms
90% 127.60ms
99% 206.27ms
19753 requests in 15.10s, 2.41MB read
Requests/sec: 1308.35
Transfer/sec: 163.76KB
Running 15s test @ http://127.0.0.1:8081/upvote/random
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.28s 623.37ms 3.52s 76.48%
Req/Sec 96.99 56.02 290.00 64.44%
Latency Distribution
50% 1.09s
75% 1.57s
90% 2.22s
99% 3.06s
11021 requests in 15.10s, 1.35MB read
Socket errors: connect 0, read 4464, write 14, timeout 0
Requests/sec: 729.82
Transfer/sec: 91.24KB
随机查询点赞数量
Running 15s test @ http://127.0.0.1:8081/upvote/random/count
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 159.69ms 164.80ms 1.06s 85.51%
Req/Sec 93.75 45.13 290.00 72.45%
Latency Distribution
50% 125.30ms
75% 244.04ms
90% 377.00ms
99% 716.69ms
11202 requests in 15.09s, 1.37MB read
Requests/sec: 742.51
Transfer/sec: 92.69KB
Running 15s test @ http://127.0.0.1:8081/upvote/random/count
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.72s 560.49ms 3.31s 72.10%
Req/Sec 70.20 37.27 280.00 64.61%
Latency Distribution
50% 1.75s
75% 2.10s
90% 2.46s
99% 2.78s
7962 requests in 15.08s, 0.97MB read
Socket errors: connect 0, read 5757, write 71, timeout 0
Requests/sec: 528.03
Transfer/sec: 65.84KB
随机查询是否点赞
Running 15s test @ http://127.0.0.1:8081/upvote/random/voted
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 143.77ms 123.50ms 960.63ms 74.31%
Req/Sec 94.14 44.22 272.00 66.52%
Latency Distribution
50% 114.05ms
75% 212.47ms
90% 307.71ms
99% 545.08ms
11113 requests in 15.10s, 1.37MB read
Requests/sec: 735.87
Transfer/sec: 92.81KB
Running 15s test @ http://127.0.0.1:8081/upvote/random/voted
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.60s 492.57ms 3.11s 78.52%
Req/Sec 74.13 42.23 352.00 66.27%
Latency Distribution
50% 1.57s
75% 1.86s
90% 2.20s
99% 2.79s
8320 requests in 15.09s, 1.02MB read
Socket errors: connect 0, read 4051, write 278, timeout 0
Requests/sec: 551.44
Transfer/sec: 69.46KB
Redis + Kafka + Mysql 测试情况
随机写入点赞数据
Running 15s test @ http://127.0.0.1:8081/upvote/random
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 59.28ms 12.85ms 115.39ms 69.38%
Req/Sec 202.63 43.91 393.00 73.50%
Latency Distribution
50% 58.69ms
75% 66.57ms
90% 75.54ms
99% 100.70ms
24317 requests in 15.08s, 2.97MB read
Requests/sec: 1612.34
Transfer/sec: 201.79KB
Running 15s test @ http://127.0.0.1:8081/upvote/random
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 536.15ms 146.29ms 1.02s 70.19%
Req/Sec 235.21 207.01 1.02k 70.95%
Latency Distribution
50% 515.93ms
75% 634.72ms
90% 726.52ms
99% 923.41ms
25059 requests in 15.09s, 3.06MB read
Socket errors: connect 0, read 6929, write 108, timeout 0
Requests/sec: 1660.10
Transfer/sec: 207.53KB
随机查询点赞数量
Running 15s test @ http://127.0.0.1:8081/upvote/random/count
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 16.42ms 8.79ms 154.03ms 88.85%
Req/Sec 761.49 211.02 1.61k 77.67%
Latency Distribution
50% 14.68ms
75% 17.33ms
90% 22.60ms
99% 54.78ms
91281 requests in 15.05s, 10.91MB read
Requests/sec: 6063.49
Transfer/sec: 741.87KB
Running 15s test @ http://127.0.0.1:8081/upvote/random/count
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 131.90ms 40.43ms 531.49ms 76.71%
Req/Sec 0.91k 379.89 2.01k 77.34%
Latency Distribution
50% 121.78ms
75% 142.15ms
90% 186.37ms
99% 282.30ms
103664 requests in 15.09s, 12.39MB read
Socket errors: connect 0, read 6273, write 96, timeout 0
Requests/sec: 6871.93
Transfer/sec: 840.78KB
随机查询是否点赞
Running 15s test @ http://127.0.0.1:8081/upvote/random/voted
8 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 20.76ms 46.51ms 2.15s 98.79%
Req/Sec 655.35 243.15 1.55k 75.25%
Latency Distribution
50% 16.47ms
75% 20.96ms
90% 31.04ms
99% 69.94ms
78604 requests in 15.09s, 9.68MB read
Requests/sec: 5207.97
Transfer/sec: 657.04KB
Running 15s test @ http://127.0.0.1:8081/upvote/random/voted
8 threads and 1000 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 113.65ms 28.75ms 386.92ms 73.48%
Req/Sec 1.01k 382.66 2.03k 76.39%
Latency Distribution
50% 109.56ms
75% 122.12ms
90% 148.83ms
99% 211.10ms
119410 requests in 15.10s, 14.71MB read
Socket errors: connect 0, read 7855, write 85, timeout 0
Requests/sec: 7909.80
Transfer/sec: 0.97MB
总结
基于上述测试结果,总结一下各个方案的优缺点:
方案 | 优点 | 缺点 | 备注 |
---|---|---|---|
优先队列 | 高可用、高一致、易扩展、易实现 | 成本要求较高 | 适合数据量不高,需求不明确的场景 |
关系型数据库 | 高一致、强持久、低成本、易实现 | 并发度低,难水平扩展 | 适合低并发场景 |
缓存+消息队列+关系型数据库 | 高可用、强一致、强持久、低成本、易扩展 | 实现复杂,存在过度设计风险 | 适合高并发、数据量大、需求明确的场景 |
基于上述优缺点,个人建议:
- 大部分业务场景,在千万级数据量级别时,可以直接选择方案一,因为实现简单,且可以向任意方案扩展
- 基础数据量较大,且增速较快,可以选择方案三,因为扩展性和一致性都更强。通常数据量的增长与并发度呈正相关,大数据量意味着高并发
- 业务系统简单,用户量在十万级别时,可以选择方案二,基本不会出现性能瓶颈。但这种情况,方案一也大概率可以覆盖
实践中,选择方案一可以满足大部分业务场景。 点赞计数是一个拥有热点数据的场景,可以通过冷数据入库的方式来节约内存成本。比如三个月以前的数据可以选择从Redis中清除,持久化到数据库。在查询过程中,可以通过一个BloomFilter存储对应主体的数据是否已经持久化到关系型数据库。通过这个结果来判断访问内存还是访问关系型数据库。
实际业务中,不同场景可以有不同优化方案。比如用户对看到的点赞数、是否点赞等数据的一致性要求并不是那么高,可以通过客户端缓存等方式降低对服务端一致性的要求和访问压力。具体情况具体分析,本文的设计方案只针对服务端进行设计。
后记
技术方案设计过程中,除了进行经验评估,往往还需要在实践中来验证自己的想法。 在这次测试过程中,遇到过许多与设计预期不符合的情况,如:
- Redis内存消耗大于方案设计时的预期,原因是没考虑到跳表的索引节点开销
- BloomFilter设计不合理,导致Redis成为写入瓶颈,原因是参数不合理,导致需要数十次Redis访问
- 受连接池配置、Web服务器配置影响,测试结果远低于预期等
这些问题都是在实践过程中遇到后,逐步分析,一个一个解决的。这些问题再次印证那一句名言:
实践是检验真理的唯一标准。