Skip to content

Commit 18f10c7

Browse files
committed
add实时订阅推送
1 parent 9b6ce29 commit 18f10c7

File tree

6 files changed

+404
-1
lines changed

6 files changed

+404
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@
242242
- [微信红包系统如何设计?](docs/advance/system-design/6-wechat-redpacket-design.md)
243243
- [如何把一个文件较快的发送到100w个服务器?](docs/advance/system-design/7-file-send.md)
244244
- [如何用 Redis 统计用户访问量?](docs/advance/system-design/10-pdd-visit-statistics)
245+
- [实时订阅推送设计](docs/advance/system-design/11-realtime-subscribe-push.md)
245246

246247
# 安全
247248

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
## 前言
2+
3+
SELECT COUNT(*)会不会导致全表扫描引起慢查询呢?
4+
5+
```
6+
SELECT COUNT(*) FROM SomeTable
7+
```
8+
9+
网上有一种说法,针对无 where_clause 的 **COUNT(\*)**,MySQL 是有优化的,优化器会选择成本最小的辅助索引查询计数,其实反而性能最高,这种说法对不对呢
10+
11+
针对这个疑问,我首先去生产上找了一个千万级别的表使用 EXPLAIN 来查询了一下执行计划
12+
13+
```
14+
EXPLAIN SELECT COUNT(*) FROM SomeTable
15+
```
16+
17+
结果如下
18+
19+
![](C:\Users\Tyson\Desktop\img\0103\select count\select-count1.png)
20+
21+
如图所示: 发现确实此条语句在此例中用到的并不是主键索引,而是辅助索引,实际上在此例中我试验了,不管是 COUNT(1),还是 COUNT(*),MySQL 都会用**成本最小**的辅助索引查询方式来计数,也就是使用 COUNT(*) 由于 MySQL 的优化已经保证了它的查询性能是最好的!随带提一句,COUNT(*)是 SQL92 定义的标准统计行数的语法,并且效率高,所以请直接使用COUNT(*)查询表的行数!
22+
23+
所以这种说法确实是对的。但有个前提,在 MySQL 5.6 之后的版本中才有这种优化。
24+
25+
那么这个成本最小该怎么定义呢,有时候在 WHERE 中指定了多个条件,为啥最终 MySQL 执行的时候却选择了另一个索引,甚至不选索引?
26+
27+
本文将会给你答案,本文将会从以下两方面来分析
28+
29+
- SQL 选用索引的执行成本如何计算
30+
- 实例说明
31+
32+
## SQL 选用索引的执行成本如何计算
33+
34+
就如前文所述,在有多个索引的情况下, 在查询数据前,MySQL 会选择成本最小原则来选择使用对应的索引,这里的成本主要包含两个方面。
35+
36+
- IO 成本: 即从磁盘把数据加载到内存的成本,默认情况下,读取数据页的 IO 成本是 1,MySQL 是以页的形式读取数据的,即当用到某个数据时,并不会只读取这个数据,而会把这个数据相邻的数据也一起读到内存中,这就是有名的程序局部性原理,所以 MySQL 每次会读取一整页,一页的成本就是 1。所以 IO 的成本主要和页的大小有关
37+
- CPU 成本:将数据读入内存后,还要检测数据是否满足条件和排序等 CPU 操作的成本,显然它与行数有关,默认情况下,检测记录的成本是 0.2。
38+
39+
## 实例说明
40+
41+
为了根据以上两个成本来算出使用索引的最终成本,我们先准备一个表(以下操作基于 MySQL 5.7.18)
42+
43+
```
44+
CREATE TABLE `person` (
45+
`id` bigint(20) NOT NULL AUTO_INCREMENT,
46+
`name` varchar(255) NOT NULL,
47+
`score` int(11) NOT NULL,
48+
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
49+
PRIMARY KEY (`id`),
50+
KEY `name_score` (`name`(191),`score`),
51+
KEY `create_time` (`create_time`)
52+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
53+
```
54+
55+
这个表除了主键索引之外,还有另外两个索引, name_score 及 create_time。然后我们在此表中插入 10 w 行数据,只要写一个存储过程调用即可,如下:
56+
57+
```
58+
CREATE PROCEDURE insert_person()
59+
begin
60+
declare c_id integer default 1;
61+
while c_id<=100000 do
62+
insert into person values(c_id, concat('name',c_id), c_id+100, date_sub(NOW(), interval c_id second));
63+
set c_id=c_id+1;
64+
end while;
65+
end
66+
```
67+
68+
插入之后我们现在使用 EXPLAIN 来计算下统计总行数到底使用的是哪个索引
69+
70+
```
71+
EXPLAIN SELECT COUNT(*) FROM person
72+
```
73+
74+
![图片](https://mmbiz.qpic.cn/mmbiz_png/OyweysCSeLXtag3Q38nMHUxszkvkXfFaXejKpeg0pWWQcs16SwlOR1o1Fo5nw1B2RCAME61LN3hH3E5uDQuicXQ/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)
75+
76+
从结果上看它选择了 create_time 辅助索引,显然 MySQL 认为使用此索引进行查询成本最小,这也是符合我们的预期,使用辅助索引来查询确实是性能最高的!
77+
78+
我们再来看以下 SQL 会使用哪个索引
79+
80+
```
81+
SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2020-05-23 14:39:18'
82+
```
83+
84+
![图片](https://mmbiz.qpic.cn/mmbiz_png/OyweysCSeLXtag3Q38nMHUxszkvkXfFa4XR8ibNMfganIREu5JQF2bJYt0r7tZpYWdCcIaIW4emVC9SVWiajULYw/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)
85+
86+
用了全表扫描!理论上应该用 name_score 或者 create_time 索引才对,从 WHERE 的查询条件来看确实都能命中索引,那是否是使用 **SELECT \*** 造成的回表代价太大所致呢,我们改成覆盖索引的形式试一下
87+
88+
```
89+
SELECT create_time FROM person WHERE NAME >'name84059' AND create_time > '2020-05-23 14:39:18'
90+
```
91+
92+
结果 MySQL 依然选择了全表扫描!这就比较有意思了,理论上采用了覆盖索引的方式进行查找性能肯定是比全表扫描更好的,为啥 MySQL 选择了全表扫描呢,既然它认为全表扫描比使用覆盖索引的形式性能更好,那我们分别用这两者执行来比较下查询时间吧
93+
94+
```
95+
-- 全表扫描执行时间: 4.0 ms
96+
SELECT create_time FROM person WHERE NAME >'name84059' AND create_time>'2020-05-23 14:39:18'
97+
98+
-- 使用覆盖索引执行时间: 2.0 ms
99+
SELECT create_time FROM person force index(create_time) WHERE NAME >'name84059' AND create_time>'2020-05-23 14:39:18'
100+
```
101+
102+
从实际执行的效果看使用覆盖索引查询比使用全表扫描执行的时间快了一倍!说明 MySQL 在查询前做的成本估算不准!我们先来看看 MySQL 做全表扫描的成本有多少。
103+
104+
前面我们说了成本主要 IO 成本和 CPU 成本有关,对于全表扫描来说也就是分别和聚簇索引占用的页面数和表中的记录数。执行以下命令
105+
106+
```
107+
SHOW TABLE STATUS LIKE 'person'
108+
```
109+
110+
![图片](https://mmbiz.qpic.cn/mmbiz_png/OyweysCSeLXtag3Q38nMHUxszkvkXfFaBxFRZE5oFBbjrVryW8vMSG0GLuznuVA6vd69ZdEw09rmQYKaSy9S1Q/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)
111+
112+
可以发现
113+
114+
1. 行数是 100264,我们不是插入了 10 w 行的数据了吗,怎么算出的数据反而多了,其实这里的计算是**估算**,也有可能这里的行数统计出来比 10 w 少了,估算方式有兴趣大家去网上查找,这里不是本文重点,就不展开了。得知行数,那我们知道 CPU 成本是 100264 * 0.2 = 20052.8。
115+
2. 数据长度是 5783552,InnoDB 每个页面的大小是 16 KB,可以算出页面数量是 353。
116+
117+
也就是说全表扫描的成本是 20052.8 + 353 = 20406。
118+
119+
这个结果对不对呢,我们可以用一个工具验证一下。在 MySQL 5.6 及之后的版本中,我们可以用 optimizer trace 功能来查看优化器生成计划的整个过程 ,它列出了选择每个索引的执行计划成本以及最终的选择结果,我们可以依赖这些信息来进一步优化我们的 SQL。
120+
121+
optimizer_trace 功能使用如下
122+
123+
```
124+
SET optimizer_trace="enabled=on";
125+
SELECT create_time FROM person WHERE NAME >'name84059' AND create_time > '2020-05-23 14:39:18';
126+
SELECT * FROM information_schema.OPTIMIZER_TRACE;
127+
SET optimizer_trace="enabled=off";
128+
```
129+
130+
执行之后我们主要观察使用 name_score,create_time 索引及全表扫描的成本。
131+
132+
先来看下使用 name_score 索引执行的的预估执行成本:
133+
134+
```
135+
{
136+
"index": "name_score",
137+
"ranges": [
138+
"name84059 <= name"
139+
],
140+
"index_dives_for_eq_ranges": true,
141+
"rows": 25372,
142+
"cost": 30447
143+
}
144+
```
145+
146+
可以看到执行成本为 30447,高于我们之前算出来的全表扫描成本:20406。所以没选择此索引执行
147+
148+
注意:这里的 30447 是查询二级索引的 IO 成本和 CPU 成本之和,再加上回表查询聚簇索引的 IO 成本和 CPU 成本之和。
149+
150+
再来看下使用 create_time 索引执行的的预估执行成本:
151+
152+
```
153+
{
154+
"index": "create_time",
155+
"ranges": [
156+
"0x5ec8c516 < create_time"
157+
],
158+
"index_dives_for_eq_ranges": true,
159+
"rows": 50132,
160+
"cost": 60159,
161+
"cause": "cost"
162+
}
163+
```
164+
165+
可以看到成本是 60159,远大于全表扫描成本 20406,自然也没选择此索引。
166+
167+
再来看计算出的全表扫描成本:
168+
169+
```
170+
{
171+
"considered_execution_plans": [
172+
{
173+
"plan_prefix": [
174+
],
175+
"table": "`person`",
176+
"best_access_path": {
177+
"considered_access_paths": [
178+
{
179+
"rows_to_scan": 100264,
180+
"access_type": "scan",
181+
"resulting_rows": 100264,
182+
"cost": 20406,
183+
"chosen": true
184+
}
185+
]
186+
},
187+
"condition_filtering_pct": 100,
188+
"rows_for_plan": 100264,
189+
"cost_for_plan": 20406,
190+
"chosen": true
191+
}
192+
]
193+
}
194+
```
195+
196+
注意看 cost:20406,与我们之前算出来的完全一样!这个值在以上三者算出的执行成本中最小,所以最终 MySQL 选择了用全表扫描的方式来执行此 SQL。
197+
198+
实际上 optimizer trace 详细列出了覆盖索引,回表的成本统计情况,有兴趣的可以去研究一下。
199+
200+
从以上分析可以看出, MySQL 选择的执行计划未必是最佳的,原因有挺多,就比如上文说的行数统计信息不准,再比如 MySQL 认为的最优跟我们认为不一样,我们可以认为执行时间短的是最优的,但 MySQL 认为的成本小未必意味着执行时间短。
201+
202+
## 总结
203+
204+
本文通过一个例子深入剖析了 MySQL 的执行计划是如何选择的,以及为什么它的选择未必是我们认为的最优的,这也提醒我们,在生产中如果有多个索引的情况,使用 WHERE 进行过滤未必会选中你认为的索引,我们可以提前使用 EXPLAIN, optimizer trace 来优化我们的查询语句。
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# 实时订阅推送设计与实现
2+
3+
什么是订阅推送?就是用户订阅了优惠劵的推送,在可领取前的一分钟就要把提醒信息推送到用户的app中。具体方案就是到具体的推送时间点了,coupon系统调用消息中心的推送接口,把信息推送出去。
4+
5+
下面我们分析一下这个功能的业务情景。假设公司目前注册用户6000W+,比如有一张无门槛的优惠劵下单立减20元,那么抢这张劵的人就会比较多,我们保守估计10W+,百万级别不好说。我们初定为20W万人,那么这20W条推送信息要在一分钟推送完成!并且一个用户是可以订阅多张劵的。所以我们知道了这个订阅功能的有两个突出的难点:
6+
7+
1、**推送的实效性**:推送慢了,用户会抱怨没有及时通知他们错过了开抢时机。
8+
9+
2、**推送的体量大**:爆款的神劵,人人都想抢!
10+
11+
然而推送体量又会影响到推送的实效性。这真是一个让人头疼的问题!
12+
13+
那就让我们把问题一个个解决掉吧!
14+
15+
推送的实效性的问题:当用户在领劵中心订阅了某个劵的领取提醒后,在后台就会生成一条用户的订阅提醒记录,里面记录了在哪个时间点给用户发送推送信息。所以问题就变成了系统如何快速实时选出哪些要推送的记录!
16+
17+
- 方案1:**MQ的延迟投递**。MQ虽然支持消息的延迟投递,但是不适合用来做精确时间点投递!并且用户执行订阅之后又取消订阅的话,要把发出去的MQ消息delete掉这个操作有点头大,短时间内难以落地!并且用户可以取消之后再订阅,这又涉及到去重的问题。所以MQ的方案否掉。
18+
19+
- 方案2:**传统定时任务**。这个相对来说就简单一点,用定时任务是去db里面load用户的订阅提醒记录,从中选出当前可以推送的记录。但有句话说得好任何脱离实际业务的设计都是耍流氓。下面我们就分析一下传统的定时任务到底适不适合我们的这个业务!
20+
21+
| 能否支持多机同时跑 | 一般不能,同一时刻只能单机跑。 |
22+
| ------------------ | --------------------------------------------- |
23+
| 存储数据源 | 一般是mysql或者其它传统数据库,并且是单表存储 |
24+
| 频率 | 支持秒、分、时、天,一般不能太快 |
25+
26+
如上表格所示,可以看到一般传统的定时任务存在以下缺点:
27+
28+
1、**性能瓶颈**。只有一台机在处理,在大体量数据面前力不从心!
29+
30+
2、**实效性差**。定时任务的频率不能太高,太高会业务数据库造成很大的压力!
31+
32+
3、**单点故障**。万一跑的那台机挂了,那整个业务不可用了。这是一个很可怕的事情!
33+
34+
所以传统定时任务也不太适合这个业务。
35+
36+
那有其他解决方案吗?其实可以对传统的定时任务做一个简单的改造即可!把它变成可以同时多机跑,并且实效性可以精确到秒级,并且拒绝单点故障的定时任务集群!这其中就要借助我们的强大的redis了。
37+
38+
- 方案3:**定时任务集群**
39+
40+
首先我们要定义定时任务集群要解决的三个问题!
41+
42+
1、**实效性要高**
43+
44+
2、**吞吐量要大**
45+
46+
3、**服务要稳定**,不能有单点故障
47+
48+
下面是整个定时任务集群的架构图。
49+
50+
![](http://img.topjavaer.cn/img/定时任务集群.png)
51+
52+
架构很简单:我们把用户的订阅推送记录存储到redis集群的sortedSet队列里面,且以提醒用户提醒时间戳作为score值,然后在我们个每业务server里面起一个定时器频率是秒级,我的设定就是1s,然后经过负载均衡之后从某个队列里面获取要推送的用户记录进行推送。下面我们分析以下这个架构。
53+
54+
1、性能:除去带宽等其它因素,基本与机器数成线性相关。机器数量越多吞吐量越大,机器数量少时相对的吞吐量就减少。
55+
56+
2、实效性:提高到了秒级,效果还可以接受。
57+
58+
3、单点故障?不存在的!除非redis集群或者所有server全挂了。
59+
60+
61+
62+
这里解释一下为什么用redis?
63+
64+
1. redis 可以作为一个高性能的存储db,性能要比MySQL好很多,并且支持持久化,稳定性好。
65+
66+
2. redis SortedSet队列天然支持以时间作为条件排序,完美满足我们选出要推送的记录。
67+
68+
既然方案已经有了,怎么实现呢?
69+
70+
首先我们以user_id作为key,然后mod队列数hash到redis SortedSet队列里面。为什么要这样呢,因为如果用户同时订阅了两张劵并且推送时间很近,这样的两条推送就可以合并成一条,并且这样hash也相对均匀。下面是部分代码的截图:
71+
72+
![](http://img.topjavaer.cn/img/消息订阅推送1.png)
73+
74+
然后要决定队列的数量,一般正常来说我们有多少台处理的服务器就定义多少条队列。因为队列太少,会造成队列竞争,太多可能会导致记录得不到及时处理。
75+
76+
然而最佳实践是队列数量应该是可动态配置化的,因为线上的集群机器数是会经常变的。大促的时候我们会加机器是不是,并且业务量增长了,机器数也是会增加是不是~。所以我是借用了淘宝的diamond进行队列数的动态配置。
77+
78+
![](http://img.topjavaer.cn/img/消息订阅推送2.png)
79+
80+
我们每次从队列里面取多少条记录也是可以动态配置的
81+
82+
![](http://img.topjavaer.cn/img/消息订阅推送3.png)
83+
84+
这样就可以随时根据实际的生产情况调整整个集群的吞吐量。 所以我们的定时任务集群还是具有一个特性就是支持动态调整。
85+
86+
最后一个关键组件就是负载均衡了。这个是非常重要的!因为这个做得不好就会可能导致多台机竞争同时处理一个队列,影响整个集群的效率!在时间很紧的情况下我就用了一个简单的方案,利用redis一个自增key,然后 mod 队列数量算法。这样就很大程度上就保证不会有两台机器同时去竞争一条队列。
87+
88+
![](http://img.topjavaer.cn/img/消息订阅推送4.png)
89+
90+
最后我们算一下整个集群的吞吐量
91+
92+
10(机器数) \* 2000(一次拉取数) = 20000。然后以MQ的形式把消息推送到消息中心,发MQ是异步的,算上其它处理0.5s。
93+
94+
其实发送20W的推送也就是10几s的事情。
95+
96+
到这里我们整个定时任务集群就差不多基本落地好了。反过来仔细思考了一下,有以下可以**优化的点**
97+
98+
1、加监控, 集群怎么可以木有监控呢,万一出问题有任务堆积怎么办。
99+
100+
2、加上可视化界面。
101+
102+
3、最好有智能调度,增加任务优先级。确保优先级高的任务先运行。
103+
104+
4、资源调度,万一机器数量不够,力不从心,优先保证重要任务执行。
105+
106+
107+
108+
目前项目已上线,运行稳定。
109+
110+
111+
112+
> 参考链接:https://www.cnblogs.com/linlinismine/p/9214299.html

0 commit comments

Comments
 (0)