敖丙

敖丙 查看完整档案

杭州编辑北京邮电大学  |  计算机科学与技术 编辑杭州某电商  |  算法工程 编辑 juejin.im/user/59b416065188257e671b670a 编辑
编辑

个人动态

敖丙 发布了文章 · 1月11日

一年40W粉,小小程序员的2020年终总结

这一年世界发生了太多事,山火、疫情、洪水、大选.......魔幻而过得飞快。

而这一年对我来说,是机遇,也是挑战,所幸付出有回响,努力有回报。自媒体这条路不知不觉我就走了一年,从200天、300天的时候就在盼这一天,总感觉坚持一年就可以给自己一个交代了。

等真的到了这一天,心里反而有点平静了,又赶上双十一、搬家,忙着忙着也就过去了(忘了)。直到这两天得了流感生病在家,这才想起来应该总结一下,是对过去的一次复盘,也是对未来的一份期许。

故事的开始总是猝不及防,杭州深秋,夜雨淅淅沥沥。

下班后的办公室静悄悄,三歪窝在电脑前,手指翻飞,不时皱着眉头停下来思考,他还在肝文章。

我就着新相机的新鲜感,又拍摄了几个镜头。掏出手机翻了翻自己b站视频播放,三天了,数字还是没有突破200,这还是我已经发了朋友圈的结果,想到朋友圈也就300人,我好像又明白了些什么。

心里有点闷,我走过去拉了把椅子坐下,还没等我开口。

“今天的拍完了?你的B站我看了,说实话有点垃圾”

“我也知道,说实话那种东西有人看才有鬼了”

mogu的logo在雨里模糊成粉色的光,我长叹了一口气,三歪拿起一根华子递给我,我摆摆手,指向他的屏幕。

“你觉得我可以么”

“有什么不可以,你这么有才,还幽默,在我心里你就是最棒的”

“那我真的写咯”

“写呗,这样周末还有个伴,陪我写文章哈哈”

我们都没意识到,那天无意的对话改变了我这一年的轨迹。

三歪说既然视频没做出啥起色,就试试文章,我这一试就写到了今天。

从第一篇文章开始,我就霸占了当时博客网站的热榜,这给了我极大的动力。

来新东家的那次跳槽我拿了很多offer,我一想自己面试这么擅长为啥不写面试题材的?因为性格不羁的原因我就想取个浮夸的名字,就有了当时很火的《吊打面试官》系列,后面模仿的小伙伴越来越多,甚至发现很多大学生也取这种标题,意识到自己好像做了反面教材,连夜把全网这个系列文章的名字都改了,后来为这事还上了次热搜,被人匿名喷我带坏了风气。

言语无力,索性更努力的改善自己内容的广度深度,到现在虽然没了系列,不过文风却更多样化了。

开篇说了最开始我目标是做视频博主,后来却在写文的路上越走越远。

所谓念念不忘,必有回响,疫情期间没忍住,最终还是买了相机开始折腾。星球的小伙伴问的问题总是面试相关,我就想着不如就来个模拟面试,这样拍成视频大家都能看到。

后来的事情大家也知道了,拍着拍着突然就火了,在B站也带起了一波面试的节奏。

至于最近为啥断更几个月,忙是一方面,还有一方面在思考内容的扩展,我可以通过面试视频涨很多很多粉,但更想通过内容留住更多的人,毕竟大家也不会喜欢看一个只会面试的人一辈子吧,是吧?

再说说生活,老粉就能发现,其实下半年我特别能折腾,先是去深圳见了帅地,uzi、良许,吴师兄他们。

又在苏州和互联网技术号的头部们碰了一面,这次见的大佬们很多我都是看着他们文章成长的,反正我是没想到网罗灯下黑的主人是个帅气大叔,每日优搜罗的号主是个刚毕业的大帅比,还有拉皮条的勇哥。。。。。哦对,请我在上海坐豪华游轮的堂妹我也还是得单独说一下,毕竟是商汤的顶流程序员妹子。

入秋又跑了好几趟上海,在华为嘉年华上面基了Chris和在下小苏他们,在B站邀请会见到了变形兄弟,凉仔,毕导这些大博主,已经约了明年的一些梦幻联动,大家也可以期待一下。

我合作方也多了腾讯,B站,华为,阿里云,拼多多等这样的大企业,从产品的使用者,到他们的推广者,总算也有能洽上心仪饭的一天了。

这些人和企业在年初对我来说,还只是遥不可及的名字,所以接触的时候还是心里一颤,惊觉自己原来已经走了这么远,站的这么高。

今年最最让我满意的就还是挑起了家里的大梁吧,年初爸爸所在的工厂因为疫情要很迟才开工,我索性叫他别出去打工了,在家随便找个工作混着,老妈呆家里就好了,赚钱养家的事情就我来吧。

老家路远又难走,给爸爸买了车,这样出门能方便点,外婆也可以经常接来住。国庆回家,看到爸妈和外婆在家生活的富足,就觉得终于也可以给家里遮风挡雨了。(知道我为啥要恰辣么多饭了吧,因为我赚了着一家人的工资哈哈哈哈)

一切看起来都是好的,这中间有什么不好的么?

有人经常问我,你是怎么做到这么高产出的呀,我的回答其实一般都是比较直接,两个字 ”熬夜“,至于周末嘛,我好像一年都没完整的过过周末,就算出去玩也都提前肝好存货出门的。

这么肝,身体免疫力会很差劲,我本身就不是强壮的人,我来杭州四年了,每年的11月12月我都会得流感,这不这两天又中招了,大家好奇我怎么会有发热门诊护士的微信,真的不是因为我骚,是因为我去得太勤了想记不住都难。

说到这里,大家是不是一直对我感情也挺好奇的,其实去年我有交往过一个模特女朋友,她很好,但是我要忙的太多,忽略了很多细节,别说她,是我我也要跟敖丙这种人分手,都没恋爱的感觉,呸渣男。。。

注:照片经过本人同意 我知道后台100%会有又喷我别乱发别人照片,我所有照片会经过别人同意的哟乖,自媒体版权这件事情我比大家认知都深刻

至于现在,虽然身边有很多不错的机会,但是我是那种口嗨着好惨啊单身狗,但是别人一介绍就NONONONO的人,而且我觉得现阶段的自己这样挺好,等我确定自己能有精力付出和陪伴另一半的时候, 再去迎接到来的感情吧。

说回工作,大家也知道年初公司就因为裁员上了热搜,很多好朋友虽然现在已经不在mogu了,但是我们还是经常聚会爬山聚餐什么的。我自己呢也来到了大数据团队,我算是从电商的业务线,中台线,又到了大数据底层侧了,有朋友可能会比较烦这种变动,好不容易熟悉的又给我搞到不熟悉的,但我这个人就是喜欢充满挑战性的事情,所以放马过来吧。

这一年,在全网积累了40多万的粉丝,在程序员自媒体里面确实已经算得上头部顶流,在公司同事也总是调侃着喊我大v,但是因为见过更高的山更厉害的人,所以更知道自己要走的路还有很远,也没把自己当回事,认识我的人呢把我当正常人就好了。

做自媒体这件事情我运气还是挺好的,熬过的夜,码下的字也没有被辜负。最开始我高产是因为发现我有趣的文章大家看了有帮助,也被很多人喜欢,对我来说足以。不过现在呢大家也看到了我广告不少,反正被骂习惯了,熟悉的朋友也发现了多少个广告对应多少个原创,这也是我周末不到处玩,看书学习更新的动力,我觉得既然恰了饭,就一定要拿出干货对得起大家。

不然去跟妹子喝喝酒,爬爬山还是很惬意的呀,谁愿意天天在位置上顶着腰疼,顶着孤独做这些呢是吧,还可能被喷自闭。

我不是什么清高的人,我比任何人都喜欢钱,也比任何人都在意赚钱这件事情,从实习几千工资到现在,我穷怕了,我一直没放弃过研究如何赚钱,如何钱生钱。不过君子爱财,取之有道,我也有自己的坚守。

其实我完全可以赚更多的钱,你们看我文章会发现我今年几乎都不开赞赏功能,也不使用腾讯团队内测的付费功能,B站直播我也拒绝大家刷东西。

还有大家也发现我的博客网站,视频网站,目前都没什么恰饭,并不是缺广告商,主要是因为我觉得既然公号恰了,别的地方就算了,还大家一片清净,还有朋友圈和交流群,虽然好多人拿着不菲的广告费找过我,但我都一口拒了,一码归一码,这也是我最后的坚守。

这一年,从跳槽来新东家,经历裁员,疫情,从上海的夜晚到北京的深秋,见过半夜的西湖,也顶着晨光回家,经历得越多,认识的人越多,知道的故事就越多,每个人都有不一样的生活,都有不被限制而闪闪发光的灵魂,而我又有这样的平台,作为为数不多的程序员自媒体,我还是希望内容的本身能给大家带来想要收获的同时,也分享身边有趣的人和事,知道这个世界上有这样一群人,以这样的方式活的酣畅淋漓。

其实一周年的文章在我生病的时候写有点多愁善感,不过万千感慨不吐不快,我也一直留着汗在写,如果说错什么,还望海涵。

感谢这一年里每一份关注,愿你我不负相遇,都有收获。就像我们第一次在这里见面那样,我说希望你在这里悄悄拔尖,然后惊艳所有人。

我是敖丙,你知道的越多,不知道的越多,我们下期见,respect。

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入。
查看原文

赞 4 收藏 1 评论 3

敖丙 发布了文章 · 1月11日

阿里面试官:MySQL如何设计索引更高效?

有情怀,有干货,微信搜索【三太子敖丙】关注这个不一样的程序员。

本文 GitHubhttps://github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

前言

数据库系列更新到现在我想大家对所有的概念都已有个大概认识了,这周我在看评论的时候我发现有个网友的提问我觉得很有意思:帅丙如何设计一个索引?你们都是怎么设计索引的?怎么设计更高效?

我一想索引我写过很多了呀,没道理读者还不会啊,但是我一回头看完,那确实,我就写了索引的概念,优劣势,没提到怎么设计,那这篇文章又这样应运而生了。

本文还是会有很多之前写过的重复概念,但是也是为了大家能更好的理解MySQL中几种索引设计的原理。

正文

我们知道,索引是一个基于链表实现的树状Tree结构,能够快速的检索数据,目前几乎所RDBMS数据库都实现了索引特性,比如MySQL的B+Tree索引,MongoDB的BTree索引等。

在业务开发过程中,索引设计高效与否决定了接口对应SQL的执行效率,高效的索引可以降低接口的Response Time,同时还可以降低成本,我们要现实的目标是:索引设计->降低接口响应时间->降低服务器配置->降低成本,最终要落实到成本上来,因为老板最关心的是成本

今天就跟大家聊聊MySQL中的索引以及如何设计索引,使用索引才能提降低接口的RT,提高用户体检。

MySQL中的索引

MySQL中的InnoDB引擎使用B+Tree结构来存储索引,可以尽量减少数据查询时磁盘IO次数,同时树的高度直接影响了查询的性能,一般树的高度维持在 3~4 层。

B+Tree由三部分组成:根root、枝branch以及Leaf叶子,其中root和branch不存储数据,只存储指针地址,数据全部存储在Leaf Node,同时Leaf Node之间用双向链表链接,结构如下:

从上面可以看到,每个Leaf Node是三部分组成的,即前驱指针p_prev,数据data以及后继指针p_next,同时数据data是有序的,默认是升序ASC,分布在B+tree右边的键值总是大于左边的,同时从root到每个Leaf的距离是相等的,也就是访问任何一个Leaf Node需要的IO是一样的,即索引树的高度Level + 1次IO操作。

我们可以将MySQL中的索引可以看成一张小表,占用磁盘空间,创建索引的过程其实就是按照索引列排序的过程,先在sort_buffer_size进行排序,如果排序的数据量大,sort_buffer_size容量不下,就需要通过临时文件来排序,最重要的是通过索引可以避免排序操作(distinct,group by,order by)。

聚集索引

MySQL中的表是IOT(Index Organization Table,索引组织表),数据按照主键id顺序存储(逻辑上是连续,物理上不连续),而且主键id是聚集索引(clustered index),存储着整行数据,如果没有显示的指定主键,MySQL会将所有的列组合起来构造一个row_id作为primary key,例如表users(id, user_id, user_name, phone, primary key(id)),id是聚集索引,存储了id, user_id, user_name, phone整行的数据。

辅助索引

辅助索引也称为二级索引,索引中除了存储索引列外,还存储了主键id,对于user_name的索引idx_user_name(user_name)而言,其实等价于idx_user_name(user_name, id),MySQL会自动在辅助索引的最后添加上主键id,熟悉Oracle数据库的都知道,索引里除了索引列还存储了row_id(代表数据的物理位置,由四部分组成:对象编号+数据文件号+数据块号+数据行号),我们在创建辅助索引也可以显示添加主键id。

-- 创建user_name列上的索引
mysql> create index idx_user_name on users(user_name);
-- 显示添加主键id创建索引
mysql> create index idx_user_name_id on users(user_name,id);
-- 对比两个索引的统计数据
mysql> select a.space as tbl_spaceid, a.table_id, a.name as table_name, row_format, space_type,  b.index_id , b.name as index_name, n_fields, page_no, b.type as index_type  from information_schema.INNODB_TABLES a left join information_schema.INNODB_INDEXES b  on a.table_id =b.table_id where a.name = 'test/users';
+-------------+----------+------------+------------+------------+----------+------------------+----------+------
| tbl_spaceid | table_id | table_name | row_format | space_type | index_id | index_name       | n_fields | page_no | index_type |
+-------------+----------+------------+------------+------------+----------+------------------+----------+------
|         518 |     1586 | test/users | Dynamic    | Single     |     1254 | PRIMARY          |        9 |       4 |          3 |
|         518 |     1586 | test/users | Dynamic    | Single     |     4003 | idx_user_name    |        2 |       5 |          0 |
|         518 |     1586 | test/users | Dynamic    | Single     |     4004 | idx_user_name_id |        2 |      45 |          0 |
mysql> select index_name, last_update, stat_name, stat_value, stat_description from mysql.innodb_index_stats where index_name in ('idx_user_name','idx_user_name_id');
+------------------+---------------------+--------------+------------+-----------------------------------+
| index_name       | last_update         | stat_name    | stat_value | stat_description                  |
+------------------+---------------------+--------------+------------+-----------------------------------+   
| idx_user_name    | 2021-01-02 17:14:48 | n_leaf_pages |       1358 | Number of leaf pages in the index |
| idx_user_name    | 2021-01-02 17:14:48 | size         |       1572 | Number of pages in the index      |
| idx_user_name_id | 2021-01-02 17:14:48 | n_leaf_pages |       1358 | Number of leaf pages in the index |
| idx_user_name_id | 2021-01-02 17:14:48 | size         |       1572 | Number of pages in the index      |

对比一下两个索引的结果,n_fields表示索引中的列数,n_leaf_pages表示索引中的叶子页数,size表示索引中的总页数,通过数据比对就可以看到,辅助索引中确实包含了主键id,也说明了这两个索引时完全一致。

Index_namen_fieldsn_leaf_pagessize
idx_user_name213581572
idx_user_name_id213581572

索引回表

上面证明了辅助索引包含主键id,如果通过辅助索引列去过滤数据有可能需要回表,举个例子:业务需要通过用户名user_name去查询用户表users的信息,业务接口对应的SQL:

select  user_id, user_name, phone from users where user_name = 'Laaa';

我们知道,对于索引idx_user_name而言,其实就是一个小表idx_user_name(user_name, id),如果只查询索引中的列,只需要扫描索引就能获取到所需数据,是不需要回表的,如下SQL语句:

SQL 1: select id, user_name from users where user_name = 'Laaa';

SQL 2: select id from users where user_name = 'Laaa';

mysql> explain select id, name from users where name = 'Laaa';
+----+-------------+-------+------------+------+---------------+---------------+---------+-------+------+-------
| id | select_type | table | partitions | type | possible_keys | key           | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+---------------+---------+-------+------+-------
|  1 | SIMPLE      | users | NULL       | ref  | idx_user_name | idx_user_name | 82      | const |    1 |   100.00 | Using index |
mysql> explain select id from users where name = 'Laaa';
+----+-------------+-------+------------+------+---------------+---------------+---------+-------+------+-------
| id | select_type | table | partitions | type | possible_keys | key           | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+---------------+---------+-------+------+-------
|  1 | SIMPLE      | users | NULL       | ref  | idx_user_name | idx_user_name | 82      | const |    1 |   100.00 | Using index |

SQL 1和SQL 2的执行计划中的Extra=Using index 表示使用覆盖索引扫描,不需要回表,再来看上面的业务SQL:

select user_id, user_name, phone from users where user_name = 'Laaa';

可以看到select后面的user_id,phone列不在索引idx_user_name中,就需要通过主键id进行回表查找,MySQL内部分如下两个阶段处理:

Section 1select **id** from users where user_name = 'Laaa' //id = 100101

Section 2:select user_id, user_name, phone from users where id = 100101;

Section 2的操作称为回表,即通过辅助索引中的主键id去原表中查找数据。

索引高度

MySQL的索引时B+tree结构,即使表里有上亿条数据,索引的高度都不会很高,通常维持在3-4层左右,我来计算下索引idx_name的高度,从上面知道索引信息:index_id = 4003, page_no = 5,它的偏移量offset就是page_no x innodo_page_size + 64 = 81984,通过hexdump进行查看

$hexdump -s 81984 -n 10 /usr/local/var/mysql/test/users.ibd
0014040 00 02 00 00 00 00 00 00 0f a3                  
001404a

其中索引的PAGE_LEVEL为00,即idx_user_name索引高度为1,0f a3 代表索引编号,转换为十进制是4003,正是index_id。

数据扫描方式

全表扫描

从左到右依次扫描整个B+Tree获取数据,扫描整个表数据,IO开销大,速度慢,锁等严重,影响MySQL的并发。

对于OLAP的业务场景,需要扫描返回大量数据,这时候全表扫描的顺序IO效率更高。

索引扫描

通常来讲索引比表小,扫描的数据量小,消耗的IO少,执行速度块,几乎没有锁等,能够提高MySQL的并发。

对于OLTP系统,希望所有的SQL都能命中合适的索引总是美好的。

主要区别就是扫描数据量大小以及IO的操作,全表扫描是顺序IO,索引扫描是随机IO,MySQL对此做了优化,增加了change buffer特性来提高IO性能。

索引优化案例

分页查询优化

业务要根据时间范围查询交易记录,接口原始的SQL如下:

select  * from trade_info where status = 0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59' order by id desc limit 102120, 20;

表trade_info上有索引idx_status_create_time(status,create_time),通过上面分析知道,等价于索引(status,create_time,id),对于典型的分页limit m, n来说,越往后翻页越慢,也就是m越大会越慢,因为要定位m位置需要扫描的数据越来越多,导致IO开销比较大,这里可以利用辅助索引的覆盖扫描来进行优化,先获取id,这一步就是索引覆盖扫描,不需要回表,然后通过id跟原表trade_info进行关联,改写后的SQL如下:

select * from trade_info a ,

(select  id from trade_info where status = 0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59' order by id desc limit 102120, 20) as b   //这一步走的是索引覆盖扫描,不需要回表
 where a.id = b.id;

很多同学只知道这样写效率高,但是未必知道为什么要这样改写,理解索引特性对编写高质量的SQL尤为重要。

分而治之总是不错的

营销系统有一批过期的优惠卷要失效,核心SQL如下:

-- 需要更新的数据量500w
update coupons set status = 1 where status =0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59';

在Oracle里更新500w数据是很快,因为可以利用多个cpu core去执行,但是MySQL就需要注意了,一个SQL只能使用一个cpu core去处理,如果SQL很复杂或执行很慢,就会阻塞后面的SQL请求,造成活动连接数暴增,MySQL CPU 100%,相应的接口Timeout,同时对于主从复制架构,而且做了业务读写分离,更新500w数据需要5分钟,Master上执行了5分钟,binlog传到了slave也需要执行5分钟,那就是Slave延迟5分钟,在这期间会造成业务脏数据,比如重复下单等。

优化思路:先获取where条件中的最小id和最大id,然后分批次去更新,每个批次1000条,这样既能快速完成更新,又能保证主从复制不会出现延迟。

优化如下:

  1. 先获取要更新的数据范围内的最小id和最大id(表没有物理delete,所以id是连续的)
mysql> explain select min(id) min_id, max(id) max_id from coupons where status =0 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59'; 
+----+-------------+-------+------------+-------+------------------------+------------------------+---------+---
| id | select_type | table | partitions | type  | possible_keys          | key                    | key_len | ref  | rows   | filtered | Extra                    |
+----+-------------+-------+------------+-------+------------------------+------------------------+---------+---
|  1 | SIMPLE      | users | NULL       | range | idx_status_create_time | idx_status_create_time | 6       | NULL | 180300 |   100.00 | Using where; Using index |

​ Extra=Using where; Using index使用了索引idx_status_create_time,同时需要的数据都在索引中能找到,所以不需要回表查询数据。

  1. 以每次1000条commit一次进行循环update,主要代码如下:
current_id = min_id;
for  current_id < max_id do
  update coupons set status = 1 where id >=current_id and id <= current_id + 1000;  //通过主键id更新1000条很快
commit;
current_id += 1000;
done

这两个案例告诉我们,要充分利用辅助索引包含主键id的特性,先通过索引获取主键id走覆盖索引扫描,不需要回表,然后再通过id去关联操作是高效的,同时根据MySQL的特性使用分而治之的思想既能高效完成操作,又能避免主从复制延迟产生的业务数据混乱。

MySQL索引设计

熟悉了索引的特性之后,就可以在业务开发过程中设计高质量的索引,降低接口的响应时间。

前缀索引

对于使用REDUNDANT或者COMPACT格式的InnoDB表,索引键前缀长度限制为767字节。如果TEXT或VARCHAR列的列前缀索引超过191个字符,则可能会达到此限制,假定为utf8mb4字符集,每个字符最多4个字节。

可以通过设置参数innodb_large_prefix来开启或禁用索引前缀长度的限制,即是设置为OFF,索引虽然可以创建成功,也会有一个警告,主要是因为index size会很大,效率大量的IO的操作,即使MySQL优化器命中了该索引,效率也不会很高。

-- 设置innodb_large_prefix=OFF禁用索引前缀限制,虽然可以创建成功,但是有警告。
mysql> create index idx_nickname on users(nickname);    // `nickname` varchar(255)
Records: 0  Duplicates: 0  Warnings: 1
mysql> show warnings;
+---------+------+---------------------------------------------------------+
| Level   | Code | Message                                                 |
+---------+------+---------------------------------------------------------+
| Warning | 1071 | Specified key was too long; max key length is 767 bytes |

业务发展初期,为了快速实现功能,对一些数据表字段的长度定义都比较宽松,比如用户表users的昵称nickname定义为varchar(128),而且有业务接口需要通过nickname查询,系统运行了一段时间之后,查询users表最大的nickname长度为30,这个时候就可以创建前缀索引来减小索引的长度提升性能。

-- `nickname` varchar(128) DEFAULT NULL定义的执行计划
mysql> explain select * from users where nickname = 'Laaa';
+----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+--------
| id | select_type | table | partitions | type | possible_keys | key          | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+--------------+---------+-------+------+--------
|  1 | SIMPLE      | users | NULL       | ref  | idx_nickname  | idx_nickname | 515     | const |    1 |   100.00 | NULL  |

key_len=515,由于表和列都是utf8mb4字符集,每个字符占4个字节,变长数据类型+2Bytes,允许NULL额外+1Bytes,即128 x 4 + 2 + 1 = 515Bytes。创建前缀索引,前缀长度也可以不是当前表的数据列最大值,应该是区分度最高的那部分长度,一般能达到90%以上即可,例如email字段存储都是类似这样的值xxxx@yyy.com,前缀索引的最大长度可以是xxxx这部分的最大长度即可。

-- 创建前缀索引,前缀长度为30
mysql> create index idx_nickname_part on users(nickname(30));
-- 查看执行计划
mysql> explain select * from users where nickname = 'Laaa';
+----+-------------+-------+------------+------+--------------------------------+-------------------+---------+-
| id | select_type | table | partitions | type | possible_keys                  | key               | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-------+------------+------+--------------------------------+-------------------+---------+-
|  1 | SIMPLE      | users | NULL       | ref  | idx_nickname_part,idx_nickname | idx_nickname_part | 123     | const |    1 |   100.00 | Using where |

可以看到优化器选择了前缀索引,索引长度为123,即30 x 4 + 2 + 1 = 123 Bytes,大小不到原来的四分之。

前缀索引虽然可以减小索引的大小,但是不能消除排序。

mysql> explain select gender,count(*) from users where nickname like 'User100%' group by nickname limit 10;
+----+-------------+-------+------------+-------+--------------------------------+--------------+---------+-----
| id | select_type | table | partitions | type  | possible_keys                  | key          | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+-------+------------+-------+--------------------------------+--------------+---------+-----
|  1 | SIMPLE      | users | NULL       | range | idx_nickname_part,idx_nickname | idx_nickname | 515     | NULL |  899 |   100.00 | Using index condition |
--可以看到Extra= Using index condition表示使用了索引,但是需要回表查询数据,没有发生排序操作。
mysql> explain select gender,count(*) from users where nickname like  'User100%' group by nickname limit 10;
+----+-------------+-------+------------+-------+-------------------+-------------------+---------+------+------
| id | select_type | table | partitions | type  | possible_keys     | key               | key_len | ref  | rows | filtered | Extra                        |
+----+-------------+-------+------------+-------+-------------------+-------------------+---------+------+------
|  1 | SIMPLE      | users | NULL       | range | idx_nickname_part | idx_nickname_part | 123     | NULL |  899 |   100.00 | Using where; Using temporary |
--可以看到Extra= Using where; Using temporaryn表示在使用了索引的情况下,需要回表去查询所需的数据,同时发生了排序操作。

复合索引

在单列索引不能很好的过滤数据的时候,可以结合where条件中其他字段来创建复合索引,更好的去过滤数据,减少IO的扫描次数,举个例子:业务需要按照时间段来查询交易记录,有如下的SQL:

select  * from trade_info where status = 1 and create_time >= '2020-10-01 00:00:00' and create_time <= '2020-10-07 23:59:59';

开发同学根据以往复合索引的设计的经验:唯一值多选择性好的列作为复合索引的前导列,所以创建复合索idx_create_time_status是高效的,因为create_time是一秒一个值,唯一值很多,选择性很好,而status只有离散的6个值,所以认为这样创建是没问题的,但是这个经验只适合于等值条件过滤,不适合有范围条件过滤的情况,例如idx_user_id_status(user_id,status)这个是没问题的,但是对于包含有create_time范围的复合索引来说,就不适应了,我们来看下这两种不同索引顺序的差异,即idx_status_create_time和idx_create_time_status。

-- 分别创建两种不同的复合索引
mysql> create index idx_status_create_time on trade_info(status, create_time);
mysql> create index idx_create_time_status on trade_info(create_time,status);
-- 查看SQL的执行计划
mysql> explain select * from users where status = 1 and create_time >='2021-10-01 00:00:00' and create_time <= '2021-10-07 23:59:59';
+----+-------------+-------+------------+-------+-----------------------------------------------+---------------
| id | select_type | table | partitions | type  | possible_keys                                 | key                    | key_len | ref  | rows  | filtered | Extra                 |
+----+-------------+-------+------------+-------+-----------------------------------------------+---------------
|  1 | SIMPLE      | trade_info | NULL       | range | idx_status_create_time,idx_create_time_status | idx_status_create_time | 6       | NULL | 98518 |   100.00 | Using index condition |

从执行计划可以看到,两种不同顺序的复合索引都存在的情况,MySQL优化器选择的是idx_status_create_time索引,那为什么不选择idx_create_time_status,我们通过optimizer_trace来跟踪优化器的选择。

-- 开启optimizer_trace跟踪
mysql> set session optimizer_trace="enabled=on",end_markers_in_json=on;
-- 执行SQL语句
mysql> select * from trade_info where status = 1 and create_time >='2021-10-01 00:00:00' and create_time <= '2021-10-07 23:59:59';
-- 查看跟踪结果
mysql>SELECT trace FROM information_schema.OPTIMIZER_TRACE\G;

对比下两个索引的统计数据,如下所示:

复合索引TypeRows参与过滤索引列ChosenCause
idx_status_create_timeIndex Range Scan98518status AND create_timeTrueCost低
idx_create_time_statusIndex Range Scan98518create_timeFalseCost高

MySQL优化器是基于Cost的,COST主要包括IO_COST和CPU_COST,MySQL的CBO(Cost-Based Optimizer基于成本的优化器)总是选择Cost最小的作为最终的执行计划去执行,从上面的分析,CBO选择的是复合索引idx_status_create_time,因为该索引中的status和create_time都能参与了数据过滤,成本较低;而idx_create_time_status只有create_time参数数据过滤,status被忽略了,其实CBO将其简化为单列索引idx_create_time,选择性没有复合索引idx_status_create_time好。

复合索引设计原则

  1. 将范围查询的列放在复合索引的最后面,例如idx_status_create_time。
  2. 列过滤的频繁越高,选择性越好,应该作为复合索引的前导列,适用于等值查找,例如idx_user_id_status。

这两个原则不是矛盾的,而是相辅相成的。

跳跃索引

一般情况下,如果表users有复合索引idx_status_create_time,我们都知道,单独用create_time去查询,MySQL优化器是不走索引,所以还需要再创建一个单列索引idx_create_time。用过Oracle的同学都知道,是可以走索引跳跃扫描(Index Skip Scan),在MySQL 8.0也实现Oracle类似的索引跳跃扫描,在优化器选项也可以看到skip_scan=on。

| optimizer_switch             |use_invisible_indexes=off,skip_scan=on,hash_join=on |

适合复合索引前导列唯一值少,后导列唯一值多的情况,如果前导列唯一值变多了,则MySQL CBO不会选择索引跳跃扫描,取决于索引列的数据分表情况。

mysql> explain select id, user_id,status, phone from users where create_time >='2021-01-02 23:01:00' and create_time <= '2021-01-03 23:01:00';
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+----
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+----
|  1 | SIMPLE      | users | NULL       | range  | idx_status_create_time          | idx_status_create_time | NULL    | NULL | 15636 |    11.11 | Using where; Using index for skip scan|

也可以通过optimizer_switch='skip_scan=off'来关闭索引跳跃扫描特性。

总结

本位为大家介绍了MySQL中的索引,包括聚集索引和辅助索引,辅助索引包含了主键id用于回表操作,同时利用覆盖索引扫描可以更好的优化SQL。

同时也介绍了如何更好做MySQL索引设计,包括前缀索引,复合索引的顺序问题以及MySQL 8.0推出的索引跳跃扫描,我们都知道,索引可以加快数据的检索,减少IO开销,会占用磁盘空间,是一种用空间换时间的优化手段,同时更新操作会导致索引频繁的合并分裂,影响索引性能,在实际的业务开发中,如何根据业务场景去设计合适的索引是非常重要的,今天就聊这么多,希望对大家有所帮助。

我是敖丙,你知道的越多,你不知道的越多,感谢各位的三连,我们下期见。

絮叨

敖丙把自己的面试文章整理成了一本电子书,共 1630页!

干货满满,字字精髓。目录如下,还有我复习时总结的面试题以及简历模板,现在免费送给大家。

链接:https://pan.baidu.com/s/1ZQEKJBgtYle3v-1LimcSwg 密码:wjk6

我是敖丙,你知道的越多,你不知道的越多,感谢各位人才的:点赞收藏评论,我们下期见!


文章持续更新,可以微信搜一搜「 三太子敖丙 」第一时间阅读,回复【资料】有我准备的一线大厂面试资料和简历模板,本文 GitHubhttps://github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。
查看原文

赞 24 收藏 16 评论 0

敖丙 发布了文章 · 2020-12-21

MySQL查询缓存的小奥秘

有情怀,有干货,微信搜索【三太子敖丙】关注这个不一样的程序员。

本文 GitHubhttps://github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

前言

我们知道,缓存的设计思想在RDBMS数据库中无处不在,就拿号称2500w行代码,bug堆积如山的Oracle数据库来说,SQL的执行计划可以缓存在library cache中避免再次执行相同SQL发生硬解析(语法分析->语义分析->生成执行计划),SQL执行结果缓存在RESULT CACHE内存组件中,有效的将物理IO转化成逻辑IO,提高SQL执行效率。

MySQL的QueryCache跟Oracle类似,缓存的是SQL语句文本以及对应的结果集,看起来是一个很棒的Idea,那为什么从MySQL 4.0推出之后,5.6中默认禁用,5.7中被deprecated(废弃)以及8.0版本被Removed,今天就聊聊MySQL QueryCache的前世今生。

QueryCache介绍

MySQL查询缓(QC:QueryCache)在MySQL 4.0.1中引入,查询缓存存储SELECT语句的文本以及发送给客户机的结果集,如果再次执行相同的SQL,Server端将从查询缓存中检索结果返回给客户端,而不是再次解析执行SQL,查询缓存在session之间共享,因此,一个客户端生成的缓存结果集,可以响应另一个客户端执行同样的SQL。

回到开头的问题,如何判断SQL是否共享?

通过SQL文本是否完全一致来判断,包括大小写,空格等所有字符完全一模一样才可以共享,共享好处是可以避免硬解析,直接从QC获取结果返回给客户端,下面的两个SQL是不共享滴,因为一个是from,另一个是From。

--SQL 1
select id, balance from account where id = 121;
--SQL 2
select id, balance From account where id = 121;

下面是Oracle数据库通过SQL_TEXT生成sql_id的算法,如果sql_id不一样说明就不是同一个SQL,就不共享,就会发生硬解析。

#!/usr/bin/perl -w
use Digest::MD5  qw(md5 md5_hex md5_base64);
use Math::BigInt;
my $stmt = "select id, balance from account where id = 121\0"; 
my $hash = md5 $stmt; 
my($a,$b,$msb,$lsb) = unpack("V*",$hash);
my $sqln = $msb*(2**32)+$lsb;
my $stop = log($sqln) / log(32) + 1;
my $sqlid = '';
my $charbase32 = '0123456789abcdfghjkmnpqrstuvwxyz';
my @chars = split '', $charbase32;
for($i=0; $i < $stop-1; $i++){
  my $x = Math::BigInt->new($sqln);
  my $seq = $x->bdiv(32**$i)->bmod(32);
  $sqlid = $chars[$seq].$sqlid;
}
print "SQL is:\n    $stmt \nSQL_ID is\n    $sqlid\n";

大家可以发现SQL 1和SQL 2通过代码生成的sql_id值是不一样,所以不共享。

SQL is:    select id, balance from account where id = 121 
SQL_ID is  dm5c6ck1g7bds
SQL is:    select id, balance From account where id = 121 
SQL_ID is  6xb8gvs5cmc9b

如果让你比较两个Java代码文件的内容的有何差异,只需要将这段代码理解透了,就可以改造实现自己的业务逻辑。

QueryCache配置

mysql> show variables like '%query_cache%';
+------------------------------+----------+
| Variable_name                | Value    |
+------------------------------+----------+
| have_query_cache             | YES      |
| query_cache_limit            | 1048576  |
| query_cache_min_res_unit     | 4096     |
| query_cache_size             | 16777216 |
| query_cache_type             | OFF      |
| query_cache_wlock_invalidate | OFF      |
Variable_nameDescription
have_query_cache查询缓存是否可用,YES-可用;NO-不可用,如果用标准二进制MySQL,值总是YES。
query_cache_limit控制单个查询结果集的最大尺寸,默认是1MB。
query_cache_min_res_unit查询缓存分片数据块的大小,默认是4KB,可以满足大部分业务场景。
query_cache_size查询缓存大小,单位Bytes,设置为0是禁用QueryCache,注意:不要将缓存的大小设置得太大,由于在更新过程中需要线程锁定QueryCache,因此对于非常大的缓存,您可能会看到锁争用问题。
query_cache_type当query_cache_size>0;该变量影响qc如何工作,有三个取值0,1,2,0:禁止缓存或检索缓存结果1:启用缓存,SELECT SQL_NO_CACHE的语句除外2:只缓存以SELECT SQL_CACHE开头的语句。

query_cache_min_res_unit说明

默认大小是4KB,如果有很多查询结果很小,那么默认数据块大小可能会导致内存碎片,由于内存不足,碎片可能会强制查询缓存从缓存中删除查询。

在这种情况下,可以减小query_cache_min_res_unit的值,由于修剪而删除的空闲块和查询的数量由Qcache_free_blocks和Qcache_lowmem_prunes状态变量的值给出,如果大量的查询有较大的结果集,可以增大该参数的值来提高性能。

通常开启QueryCache方式

# 修改MySQL配置文件/etc/my.cnf,添加如下配置,重启MySQL server即可。
[mysqld]
query_cache_size = 32M
query_cache_type = 1

QueryCache使用

先搞点测试数据,分别对禁用和开启QueryCache下的场景进行测试。

--创建一个用户表users,并且插入100w数据。
CREATE TABLE `users` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `name` varchar(20) NOT NULL DEFAULT '' COMMENT '姓名',
  `age` tinyint NOT NULL DEFAULT '0' COMMENT 'age',
  `gender` char(1) NOT NULL DEFAULT 'M' COMMENT '性别',
  `phone` varchar(16) NOT NULL DEFAULT '' COMMENT '手机号',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表';

select count(*) from users;
+----------+
| count(*) |
+----------+
|  1000000 |

禁用queryCache场景

在不使用QueryCache的时候,每次执行相同的查询语句,都要发生一次硬解析,消耗大量的资源。

#禁用QueryCache的配置
query_cache_size = 0
query_cache_type = 0

重复执行下面查询,观察执行时间。

--第一次执行查询语句
mysql> select * from users order by create_time desc limit 10;
+---------+------------+-----+--------+-------------+---------------------+---------------------+
| id      | name       | age | gender | phone       | create_time         | update_time         |
+---------+------------+-----+--------+-------------+---------------------+---------------------+
|  997855 | User997854 |  54 | M      | 15240540354 | 2020-12-15 14:34:50 | 2020-12-15 14:34:50 |
.......
10 rows in set (0.89 sec)
--第二次执行同样的查询语句
mysql> select * from users order by create_time desc limit 10;
+---------+------------+-----+--------+-------------+---------------------+---------------------+
| id      | name       | age | gender | phone       | create_time         | update_time         |
+---------+------------+-----+--------+-------------+---------------------+---------------------+
|  997855 | User997854 |  54 | M      | 15240540354 | 2020-12-15 14:34:50 | 2020-12-15 14:34:50 |
.......
10 rows in set (0.90 sec)
-- profile跟踪情况
mysql> show profile cpu,block io for query 1;  
+----------------------+----------+----------+------------+--------------+---------------+
| Status               | Duration | CPU_user | CPU_system | Block_ops_in | Block_ops_out |
+----------------------+----------+----------+------------+--------------+---------------+
| preparing            | 0.000022 | 0.000017 |   0.000004 |            0 |             0 |
| Sorting result       | 0.000014 | 0.000009 |   0.000005 |            0 |             0 |
| executing            | 0.000011 | 0.000007 |   0.000004 |            0 |             0 |
| Sending data         | 0.000021 | 0.000016 |   0.000004 |            0 |             0 |
| Creating sort index  | 0.906290 | 0.826584 |   0.000000 |            0 |             0 |

可以看到,多次执行同样的SQL查询语句,执行时间都是0.89s左右,几乎没有差别,同时时间主要消耗在Creating sort index阶段。

开启queryCache场景

开启查询缓存时,查询语句第一次被执行时会将SQL文本及查询结果缓存在QC中,下一次执行同样的SQL执行从QC中获取数据返回给客户端即可。

#禁用QueryCache的配置
query_cache_size = 32M
query_cache_type = 1
--第一次执行查询语句
mysql> select * from users order by create_time desc limit 10;
+---------+------------+-----+--------+-------------+---------------------+---------------------+
| id      | name       | age | gender | phone       | create_time         | update_time         |
+---------+------------+-----+--------+-------------+---------------------+---------------------+
|  997855 | User997854 |  54 | M      | 15240540354 | 2020-12-15 14:34:50 | 2020-12-15 14:34:50 |
.......
10 rows in set (0.89 sec)
--第二次执行查询语句
mysql> select * from users order by create_time desc limit 10;
+---------+------------+-----+--------+-------------+---------------------+---------------------+
| id      | name       | age | gender | phone       | create_time         | update_time         |
+---------+------------+-----+--------+-------------+---------------------+---------------------+
|  997855 | User997854 |  54 | M      | 15240540354 | 2020-12-15 14:34:50 | 2020-12-15 14:34:50 |
.......
10 rows in set (0.00 sec)
-- profile跟踪数据
mysql> show profile cpu,block io for query 3;
+--------------------------------+----------+----------+------------+--------------+---------------+
| Status                         | Duration | CPU_user | CPU_system | Block_ops_in | Block_ops_out |
+--------------------------------+----------+----------+------------+--------------+---------------+
| Waiting for query cache lock   | 0.000016 | 0.000015 |   0.000001 |            0 |             0 |
| checking query cache for query | 0.000007 | 0.000007 |   0.000000 |            0 |             0 |
| checking privileges on cached  | 0.000004 | 0.000003 |   0.000000 |            0 |             0 |
| checking permissions           | 0.000034 | 0.000033 |   0.000001 |            0 |             0 |
| sending cached result to clien | 0.000018 | 0.000017 |   0.000001 |            0 |             0 |

可以看到,第一次执行QueryCache里没有缓存SQL文本及数据,执行时间0.89s,由于开启了QC,SQL文本及执行结果被缓存在QC中,第二次执行执行同样的SQL查询语句,直接命中QC且返回数据,不需要发生硬解析,所以执行时间降低为0s,从profile里看到sending cached result to client直接发送QC中的数据返回给客户端。

查询缓存命中率

查询缓存相关的status变量

mysql>SHOW GLOBAL STATUS LIKE 'QCache\_%';
+-------------------------+----------+
| Variable_name           | Value    |
+-------------------------+----------+
| Qcache_free_blocks      | 1        |  --查询缓存中可用内存块的数目。
| Qcache_free_memory      | 33268592 |  --查询缓存的可用内存量。
| Qcache_hits             | 121      |  --从QC中获取结果集的次数。
| Qcache_inserts          | 91       |  --将查询结果集添加到QC的次数,意味着查询已经不在QC中。
| Qcache_lowmem_prunes    | 0        |  --由于内存不足而从查询缓存中删除的查询数。
| Qcache_not_cached       | 0        |  --未缓存的查询数目。
| Qcache_queries_in_cache | 106      |  --在查询缓存中注册的查询数。
| Qcache_total_blocks     | 256      |  --查询缓存中的块总数。

查询缓存命中率及平均大小

                                          Qcache_hits
Query cache hit rate = ------------------------------------------------ x 100%
                       Qcache_hits + Qcache_inserts + Qcache_not_cached
                       
                              query_cache_size = Qcache_free_memory
Query Cache Avg Query Size = --------------------------------------- 
                                     Qcache_queries_in_cache

更新操作对QC影响

举个例子,支付系统的里转账逻辑,先要锁定账户再修改余额,主要步骤如下:

Query_IDQueryDescription
1reset query cache清空查询缓存。
2select balance from account where id = 121第一次执行,未命中QC,添加到QC。
3select balance from account where id = 121命中QC,直接返回结果。
4update account set balance = balance - 1000 where id = 121更新,锁定query cche进行更新,缓存数据失效。
5select balance from account where id = 121缓存已失效,未命中,添加到QC。
6select balance from account where id = 121命中QC,直接返回结果。

对于这种情况来说,QC是不太适合的,因为第一次执行查询SQL未命中,返回结果给客户端,添加SQL文本及结果集到QC之后,下一次执行同样的SQL直接从QC返回结果,不需要硬解析操作,但是每次Update都是先更新数据,然后锁定QC然后更新缓存结果,会导致之前的缓存结果失效,再次执行相的查询SQL还是未命中,有得重新添加到QC,这样频繁的锁定QC->检查QC->添加QC->更新QC非常消耗资源,降低数据库的并发处理能力。

为何放弃QueryCache

一般业务场景

从业务系统的操作类型,可以分为OLTP(OnLine Transaction Processing 联机事务处理系统)和OLAP(OnLine Analysis Processing联机分析处理系统),对于政企业务,也可以分为BOSS(Business Operation Support System-业务操作支撑系统,简称业支)和BASS(Business Analysis Support System-业务分析支撑系统,简称经分),来总结下这两类系统的特点。

适合QueryCache的场景

首先,查询缓存QC的大小只有几MB,不适合将缓存设置得太大,由于在更新过程中需要线程锁定QueryCache,因此对于非常大的缓存,可能会看到锁争用问题。那么,哪些情况有助于从查询缓存中获益呢?以下是理想条件:

  1. 相同的查询是由相同或多个客户机重复发出的。
  2. 被访问的底层数据本质上是静态或半静态的。
  3. 查询有可能是资源密集型和/或构建简短但计算复杂的结果集,同时结果集比较小。
  4. 并发性和查询QPS都不高。

这4种情况只是理想情况下,实际的业务系统都是有CRUD操作的,数据更新比较频繁,查询接口的QPS比较高,所以能满足上面的理想情况下的业务场景实在很少,我能想到就是配置表,数据字典表这些基本都是静态或半静态的,可以时通过QC来提高查询效率。

不适合QueryCache的场景

如果表数据变化很快,则查询缓存将失效,并且由于不断从缓存中删除查询,从而使服务器负载升高,处理速度变得更慢,如果数据每隔几秒钟更新一次或更加频繁,则查询缓存不太可能合适。

同时,查询缓存使用单个互斥体来控制对缓存的访问,实际上是给服务器SQL处理引擎强加了一个单线程网关,在查询QPS比较高的情况下,可能成为一个性能瓶颈,会严重降低查询的处理速度。因此,MySQL 5.6中默认禁用了查询缓存。

删除QueryCache

The query cache is deprecated as of MySQL 5.7.20, and is removed in MySQL 8.0. Deprecation includes query_cache_type,可以看到从MySQL 5.6的默认禁用,5.7的废弃以及8.0的彻底删除,Oracle也是综合了各方面考虑做出了这样的选择。

上面聊了下适合和不适合的QueryCache的业务场景,发现这个特性对业务场景要求过于苛刻,与实际业务很难吻合,而且开启之后,对数据库并发度和处理能力都会降低很多,下面总结下为何MySQL从Disabled->Deprecated->Removed QueryCache的主要原因。

同时查询缓存碎片化还会导致服务器的负载升高,影响数据库的稳定性,在Oracle官方搜索QueryCache可以发现,有很多Bug存在,这也就决定了MySQL 8.0直接果断的Remove了该特性。

总结

上面为大家介绍了MySQL QueryCache从推出->禁用->废弃->删除的心路历程,设计之初是为了减少重复SQL查询带来的硬解析开销,同时将物理IO转化为逻辑IO,来提高SQL的执行效率,但是MySQL经过了多个版本的迭代,同时在硬件存储发展之快的今天,QC几乎没有任何收益,而且还会降低数据库并发处理能力,最终在8.0版本直接Removd掉了。

其实缓存设计思想在硬件和软件领域无处不在,硬件方面:RAID卡,CPU都有自己缓存,软件方面就太多了,OS的cache,数据库的buffer pool以及Java程序的缓存,作为一名研发工程师,需要根据业务场景选择合适缓存方案是非常重要的,如果都不合适,就需进行定制化开发缓存,来更好的Match自己的业务场景,今天就聊这么多,希望对大家有所帮助。

我是敖丙,你知道的越多,你不知道的越多,感谢各位人才的:点赞收藏评论,我们下期见!


文章持续更新,可以微信搜一搜「 三太子敖丙 」第一时间阅读,回复【资料】有我准备的一线大厂面试资料和简历模板,本文 GitHubhttps://github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。
查看原文

赞 2 收藏 1 评论 0

敖丙 赞了文章 · 2020-12-04

思否有约 | @敖丙:我是一个不安分的人

本期访谈嘉宾:敖丙
访谈编辑:袁钰涵

你见过刚毕业两年,就有着四年的大厂工作经验程序员吗?

今天这里就有一位,除了有远超同龄人的工作经验,论起面试经验,他也不输于别人,他骄傲地说,自己是一个有面试天赋的人,大部分岗位都能面试成功。

甚至为了不辜负这份天赋,他制作了关于程序员面试技巧分享的视频,发布后直接上了B站的专区热门,后来因为太多人模仿他的创意,作为一个不喜欢与别人一样的人,他停更了这系列的视频。

虽然视频停更,但他却喜欢上了自媒体这条路,于是在上一年,辞去了在大厂的工作,选择了一家不用加班的公司上班,把用工作之余的时间都拿来学习以及创作内容。

他就是@敖丙,拥有着程序员与自媒体人双重身份的少年。


大学两年修够学分,还成功拿了多家公司的 offer,但敖丙并不是学霸,更确切地说,一开始不是

大一时候的敖丙每天除了在宿舍打游戏就是在宿舍打游戏,俨然是一个网瘾少年。

从网瘾少年变为学霸,只经历了一年的时间,改变的开始是在2015年的夏天。

那时的敖丙暑假闲来无事,去了表哥所在的大学。

本来以为两人能无拘无束地玩游戏,想几点睡就几点睡,做什么都没人管。

谁知事实完全与之相反,表哥是个医学生,医学生的假期是属于自习室和图书馆的,每天看着努力学习的表哥,再看看成日玩游戏的自己,敖丙开始思考起了自己的未来,考虑到不富裕的家庭并不能为自己的就业提供太多的帮助,痛定思痛,敖丙开启了他的学霸之路。

暑假回来后,敖丙开始努力学习,但普通上课不能满足敖丙对知识吸收的渴望,在他看来,有些课程讲太慢了,不如自学效率高,不上课如何获得学分呢?

自从发现获得专业比赛的大奖可以兑换学分后,敖丙通过拿下各个比赛的大奖凑满了足以毕业的学分,敖丙的日常变成了不是学习如何做项目就是做项目,由此在大三,开启了他大厂打工的生涯。

无论是凑毕业学分还是成为新媒体人,敖丙总是努力避免去和别人走一样的路,按照他的说法那就是——不想和别人一样。


开始在大厂上班的敖丙光工作方向就发生了两次大变

大厂打工是从一个机缘开始的,大二的敖丙刚好参加了华为赞助的比赛,拿下前三名,获得了现场面试的机会,而他,面试成功了。

工作只是一个开始,在这四年的时间里,敖丙从做硬件转成了做后端,现在又在做大数据。

从硬件变成后端是因为个人更喜欢写代码,从后端变成大数据是因为2020年对于公司的冲击,导致部门大调动,面对这些变迁,敖丙的态度很平静,在他看来,只要认认真真地把事情做好就行了,然后在做好的基础上再做出成绩,剩下的就交给时间。

并且,一成不变从来不是他的作风。


在程序员生涯中,敖丙总结出了一套属于自己的工作流

敖丙曾在杭州的一家公司做电商活动项目,夜以继日地去做开发,项目与钱产生关联,一旦出现错误,会造成一个公司的损失,那段时间的压力非常大,项目组会把代码一遍又一遍地 review,拜托测试组多做测试,把 bug 都排出来,每天工作到凌晨三点不过是当时的常态。

敖丙跟着前辈们一起熬,伴随着压力与熬夜而来的,是他的成长。

正如人们所说的那样:压力最大的时候也是成长最快的时候,敖丙喜欢这种快速成长的感觉。

在一次次合作中,敖丙学到了很多,包括产品的思维、活动的理解。再后来一个需求下来了,敖丙就会去做设计思路,比如这次涉及到后面有哪些数据,哪些表我怎么去拿出来之后怎么处理,然后怎么以什么样的格式返回给后端,返回给前端,或者怎么跟其他的业务去交互,每一个项目他都要理清楚思路,他明白得把基础做好了,做踏实了,才能谈下一步。

于是在工作流程中,思维导图成为了必不可少的一部分。

👇是一张长图(这是敖丙分享的一个工作流程图,希望能对大家有帮助)


敖丙是一个踏实的程序员,同时他也是一个不安分的人

敖丙总会忍不住地去思考很多事情,比如在大一的暑假思考自己的未来,比如在老东家华为工作时思考自己想不想要打一辈子的工,赚个首付然后做一辈子的房奴,再比如写代码的时候,敖丙会努力思考如何让这行代码更加简洁、更加优雅。

忙忙碌碌一直埋头苦干的日子总是无法俘获他放飞的思想,一成不变的生活无法吸引到他,考虑清楚了更时候自己的东西后,他离开了他的前东家们,并且走上了自媒体这条路。

即使现在一边敲代码一边做自媒体,他脑子也会不断地思考什么样的代码运行起来更好,什么样的内容是大家喜欢又需要的,这些思考穿插在他每一个生活的瞬间,无论是看书、骑车还是其他时刻,他不安分,因为他的脑子从来都不安分。

周一到周五工作时间内的敖丙专心于他的程序员工作,总结出了许多工作经验与工作改进意见,而下班与假期的他则属于自己的媒体账号,坐飞机四处飞地参加活动、深夜剪视频、深夜写稿,每天思考新话题与新的社会动态,这些日子交织起来共同组成了他。

两边工作都想做好,固然是忙碌又疲惫的,当小编问道敖丙是不是很长时间没有休息的时候,敖丙说道,他确实很久没放假了,今年有过几次的旅游不过是在参加活动的时候出去顺便走了走,但他还年轻,他相信以后会有更多的时间去做自己的事情。


行业内卷是必然的,努力提升自己才是面对内卷的第一要领

谈起行业内卷时,敖丙提到身边一些比较优秀的朋友,以前他们的履历去大厂面试就像拥有了绿卡,但是如今面试也开始变得不容易了。

敖丙平时也很关注各家大厂的面试规则,有时候还会亲身去体验面试,往年阿里是不需要面试算法的,今年却加上了,现在对学校的要求也越来越高。

同时敖丙作为一个不安分的人,虽然身处于国内工作环境,却实时关注国外的互联网企业对于面试的要求,发现像 Google、Facebook 这些国人所羡慕的国外大公司,工作压力不比国内大厂小,并且进去的难度也极高。

平日里的敖丙还会专门买一些美国的编程课,去感受一下国外的工作氛围,敖丙笑称,如果不是因为疫情爆发,他今年就计划去国外溜达溜达,切实感受一下外国的编程环境,毕竟纸上得来终觉浅,于他而言,什么都比不上真正去体验实践一番。

讲到这里,敖丙说起关于年轻人为了提高薪酬频繁跳槽的事情,老实说他不太建议这样,即使像他这样不安分的人,如果不是认为公司同事之间相处不愉快、或者不喜欢公司的上班模式、或者认为在公司没有成长空间,只是为了薪资跳槽是不太理性的。

因为程序员是一个长期成长的角色,敖丙主要是基于做新媒体过程中需要更多的私人时间,无法进行加班,考虑到未来的发展进行了跳槽。


关于未来

敖丙还会继续从事程序员工作,但他为自己定了一个目标,等存了足够多的积蓄时,他就结束这种程序员叠加新媒体人的工作状态,他算了算,那一天不会来的太晚。

虽然敖丙每天都在努力赚钱、存钱、攒钱,但是比起钱,他更注重个人知识的积累,在他眼中,钱是一种自然而然的的东西,能力上去它就追随而来,他相信是金子总是会发光的。

现在的他,只需要继续努力、继续学习,继续发挥他的不安分优势,别的不考虑那么多。


小编有话说

一开始和敖丙聊微信时,感觉他是一个很高冷的人,回答简短,话不多说,直到开始了对敖丙的采访,才发现他是一个非常平易近人的人,甚至可以说有点话痨。

采访过程中,时常是小编提出一个问题后,敖丙顺着思路就能一直讲下去,小编准备的一连串问题还没用上,敖丙就把他们都回答了,这其中也穿插着许多他个人有趣的经历在里面,所以采访的过程中,敖丙说得很开心,小编也听得很高兴。

在此非常感谢敖丙愿意让我们了解他的故事,也希望这些故事能对大家有所触动。


欢迎有兴趣参与访谈的小伙伴踊跃报名,《思否有约》将把你与编程有关的故事记录下来。报名邮箱:mango@sifou.com

segmentfault公众号

查看原文

赞 16 收藏 3 评论 2

敖丙 发布了文章 · 2020-11-16

大厂都是怎么SQL调优的?

有情怀,有干货,微信搜索【三太子敖丙】关注这个不一样的程序员。

本文 GitHubhttps://github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

前言

这天我正在午休呢,公司DBA就把我喊醒了,说某库出现大量慢SQL,很快啊,很快,我还没反应过来,库就挂了,我心想现在的用户不讲武德啊,怎么在我睡觉的时候大量请求呢。

这是很常见的一个场景哈,因为很多业务开始数据量级不大,所以写sql的时候就没注意性能,等量级上去,很多业务就需要做调优了,在电商公司工作的这几年我也总结了不少,下面就分享给大家吧。

在代码开发过程中,我们都会遵循一些SQL开发规范去编写高质量SQL,来提高接口的Response Time(RT),对一些核心接口要求RT在100ms以内甚至更低。

由于业务前期数据量比较小,基本都能满足这个要求,但随着业务量的增长,数据量也随之增加,对应接口的SQL耗时也在变长,直接影响了用户的体验,这时候就需要对SQL进行优化。

优化点主要包括SQL规范性检查,表结构索引检查,SQL优化案例分析,下面从这三方面结合实际案例聊聊如何优化SQL。

SQL规范性检查

每个公司都有自己的MySQL开发规范,基本上大同小异,这里罗列一些比较重要的,我工作期间经常接触的给大家。

select检查

UDF用户自定义函数

SQL语句的select后面使用了自定义函数UDF,SQL返回多少行,那么UDF函数就会被调用多少次,这是非常影响性能的。

#getOrderNo是用户自定义一个函数用户来根据order_sn来获取订单编号
select id, payment_id, order_sn, getOrderNo(order_sn) from payment_transaction where status = 1 and create_time between '2020-10-01 10:00:00' and '2020-10-02 10:00:00';

text类型检查

如果select出现text类型的字段,就会消耗大量的网络和IO带宽,由于返回的内容过大超过max_allowed_packet设置会导致程序报错,需要评估谨慎使用。

#表request_log的中content是text类型。
select user_id, content, status, url, type from request_log where user_id = 32121;

group_concat谨慎使用

gorup_concat是一个字符串聚合函数,会影响SQL的响应时间,如果返回的值过大超过了max_allowed_packet设置会导致程序报错。

select batch_id, group_concat(name) from buffer_batch where status = 0 and create_time between '2020-10-01 10:00:00' and '2020-10-02 10:00:00';

内联子查询

在select后面有子查询的情况称为内联子查询,SQL返回多少行,子查询就需要执行过多少次,严重影响SQL性能。

select id,(select rule_name from member_rule limit 1) as rule_name, member_id, member_type, member_name, status  from member_info m where status = 1 and create_time between '2020-09-02 10:00:00' and '2020-10-01 10:00:00';

from检查

表的链接方式

在MySQL中不建议使用Left Join,即使ON过滤条件列索引,一些情况也不会走索引,导致大量的数据行被扫描,SQL性能变得很差,同时要清楚ON和Where的区别。

SELECT a.member_id,a.create_time,b.active_time FROM operation_log a LEFT JOIN member_info b ON a.member_id = b.member_id where  b.`status` = 1
and a.create_time between '2020-10-01 00:00:00' and '2020-10-30 00:00:00' limit 100, 0;

子查询

由于MySQL的基于成本的优化器CBO对子查询的处理能力比较弱,不建议使用子查询,可以改写成Inner Join。

select b.member_id,b.member_type, a.create_time,a.device_model from member_operation_log a inner join (select member_id,member_type from member_base_info where `status` = 1
and create_time between '2020-10-01 00:00:00' and '2020-10-30 00:00:00') as b on a.member_id = b.member_id;

where检查

索引列被运算

当一个字段被索引,同时出现where条件后面,是不能进行任何运算,会导致索引失效。

#device_no列上有索引,由于使用了ltrim函数导致索引失效
select id, name , phone, address, device_no from users where ltrim(device_no) = 'Hfs1212121';
#balance列有索引,由于做了运算导致索引失效
select account_no, balance from accounts where balance + 100 = 10000 and status = 1;

类型转换

对于Int类型的字段,传varchar类型的值是可以走索引,MySQL内部自动做了隐式类型转换;相反对于varchar类型字段传入Int值是无法走索引的,应该做到对应的字段类型传对应的值总是对的。

#user_id是bigint类型,传入varchar值发生了隐式类型转换,可以走索引。
select id, name , phone, address, device_no from users where user_id = '23126';
#card_no是varchar(20),传入int值是无法走索引
select id, name , phone, address, device_no from users where card_no = 2312612121;

列字符集

从MySQL 5.6开始建议所有对象字符集应该使用用utf8mb4,包括MySQL实例字符集,数据库字符集,表字符集,列字符集。避免在关联查询Join时字段字符集不匹配导致索引失效,同时目前只有utf8mb4支持emoji表情存储。

character_set_server  =  utf8mb4    #数据库实例字符集
character_set_connection = utf8mb4  #连接字符集
character_set_database = utf8mb4    #数据库字符集
character_set_results = utf8mb4     #结果集字符集

group by检查

前缀索引

group by后面的列有索引,索引可以消除排序带来的CPU开销,如果是前缀索引,是不能消除排序的。

#device_no字段类型varchar(200),创建了前缀索引。
mysql> alter table users add index idx_device_no(device_no(64));

mysql> select device_no, count(*) from users where create_time between '2020-10-01 00:00:00' and '2020-10-30 00:00:00' group by device_no;

函数运算

假设需要统计某月每天的新增用户量,参考如下SQL语句,虽然可以走create_time的索引,但是不能消除排序,可以考虑冗余一个字段stats_date date类型来解决这种问题。

select DATE_FORMAT(create_time, '%Y-%m-%d'), count(*) from users where create_time between '2020-09-01 00:00:00' and '2020-09-30 23:59:59' group by DATE_FORMAT(create_time, '%Y-%m-%d');

order by检查

前缀索引

order by后面的列有索引,索引可以消除排序带来的CPU开销,如果是前缀索引,是不能消除排序的。

字段顺序

排序字段顺序,asc/desc升降要跟索引保持一致,充分利用索引的有序性来消除排序带来的CPU开销。

limit检查

limit m,n要慎重

对于limit m, n分页查询,越往后面翻页即m越大的情况下SQL的耗时会越来越长,对于这种应该先取出主键id,然后通过主键id跟原表进行Join关联查询。

表结构检查

表&列名关键字

在数据库设计建模阶段,对表名及字段名设置要合理,不能使用MySQL的关键字,如desc, order, status, group等。同时建议设置lower_case_table_names = 1表名不区分大小写。

表存储引擎

对于OLTP业务系统,建议使用InnoDB引擎获取更好的性能,可以通过参数default_storage_engine控制。

AUTO_INCREMENT属性

建表的时候主键id带有AUTO_INCREMENT属性,而且AUTO_INCREMENT=1,在InnoDB内部是通过一个系统全局变量dict_sys.row_id来计数,row_id是一个8字节的bigint unsigned,InnoDB在设计时只给row_id保留了6个字节的长度,这样row_id取值范围就是0到2^48 - 1,如果id的值达到了最大值,下一个值就从0开始继续循环递增,在代码中禁止指定主键id值插入。

#新插入的id值会从10001开始,这是不对的,应该从1开始。
create table booking( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id',......) engine = InnoDB auto_increment = 10000;

#指定了id值插入,后续自增就会从该值开始+1,索引禁止指定id值插入。
insert into booking(id, book_sn) values(1234551121, 'N12121');

NOT NULL属性

根据业务含义,尽量将字段都添加上NOT NULL DEFAULT VALUE属性,如果列值存储了大量的NULL,会影响索引的稳定性。

DEFAULT属性

在创建表的时候,建议每个字段尽量都有默认值,禁止DEFAULT NULL,而是对字段类型填充响应的默认值。

COMMENT属性

字段的备注要能明确该字段的作用,尤其是某些表示状态的字段,要显式的写出该字段所有可能的状态数值以及该数值的含义。

TEXT类型

不建议使用Text数据类型,一方面由于传输大量的数据包可能会超过max_allowed_packet设置导致程序报错,另一方面表上的DML操作都会变的很慢,建议采用es或者对象存储OSS来存储和检索。

索引检查

索引属性

索引基数指的是被索引的列唯一值的个数,唯一值越多接近表的count(*)说明索引的选择率越高,通过索引扫描的行数就越少,性能就越高,例如主键id的选择率是100%,在MySQL中尽量所有的update都使用主键id去更新,因为id是聚集索引存储着整行数据,不需要回表,性能是最高的。

mysql> select count(*) from member_info;
+----------+
| count(*) |
+----------+
|   148416 |
+----------+
1 row in set (0.35 sec)

mysql> show index from member_base_info;
+------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| Table            | Non_unique | Key_name                   | Seq_in_index | Column_name       | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment |
+------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| member_info |          0 | PRIMARY                    |            1 | id                | A         |      131088 | NULL     | NULL   |      | BTREE      |         |               |
| member_info |          0 | uk_member_id               |            1 | member_id         | A         |      131824 | NULL     | NULL   |      | BTREE      |         |               |
| member_info |          1 | idx_create_time            |            1 | create_time       | A         |        6770 | NULL     | NULL   |      | BTREE      |         |               |
+------------------+------------+----------------------------+--------------+-------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
#Table: 表名
#Non_unique :是否为unique index,0-是,1-否。
#Key_name:索引名称
#Seq_in_index:索引中的顺序号,单列索引-都是1;复合索引-根据索引列的顺序从1开始递增。
#Column_name:索引的列名
#Collation:排序顺序,如果没有指定asc/desc,默认都是升序ASC。
#Cardinality:索引基数-索引列唯一值的个数。
#sub_part:前缀索引的长度;例如index (member_name(10),长度就是10。
#Packed:索引的组织方式,默认是NULL。
#Null:YES:索引列包含Null值;'':索引不包含Null值。
#Index_type:默认是BTREE,其他的值FULLTEXT,HASH,RTREE。
#Comment:在索引列中没有被描述的信息,例如索引被禁用。
#Index_comment:创建索引时的备注。

前缀索引

对于变长字符串类型varchar(m),为了减少key_len,可以考虑创建前缀索引,但是前缀索引不能消除group by, order by带来排序开销。如果字段的实际最大值比m小很多,建议缩小字段长度。

alter table member_info add index idx_member_name_part(member_name(10));

复合索引顺序

有很多人喜欢在创建复合索引的时候,总以为前导列一定是唯一值多的列,例如索引index idx_create_time_status(create_time, status),这个索引往往是无法命中,因为扫描的IO次数太多,总体的cost的比全表扫描还大,CBO最终的选择是走full table scan。

MySQL遵循的是索引最左匹配原则,对于复合索引,从左到右依次扫描索引列,到遇到第一个范围查询(>=, >,<, <=, between ….. and ….)就停止扫描,索引正确的索引顺序应该是index idx_status_create_time(status, create_time)。

select account_no, balance from accounts where status = 1 and create_time between '2020-09-01 00:00:00' and '2020-09-30 23:59:59';

时间列索引

对于默认字段created_at(create_time)、updated_at(update_time)这种默认就应该创建索引,这一般来说是默认的规则。

SQL优化案例

通过对慢查询的监控告警,经常发现一些SQL语句where过滤字段都有索引,但是由于SQL写法的问题导致索引失效,下面二个案例告诉大家如何通过SQL改写来查询。可以通过以下SQL来捞取最近5分钟的慢查询进行告警。

select CONCAT( '# Time: ', DATE_FORMAT(start_time, '%y%m%d %H%i%s'), '\n', '# User@Host: ', user_host, '\n', '# Query_time: ', TIME_TO_SEC(query_time),  '  Lock_time: ', TIME_TO_SEC(lock_time), '  Rows_sent: ', rows_sent, '  Rows_examined: ', rows_examined, '\n', sql_text, ';' ) FROM mysql.slow_log where start_time between current_timestamp and date_add(CURRENT_TIMESTAMP,INTERVAL -5 MINUTE);

慢查询SQL

| 2020-10-02 19:17:23 | w_mini_user[w_mini_user] @  [10.200.20.11] | 00:00:02   | 00:00:00  |         9 |        443117 | mini_user |              0 |         0 | 168387936 | select id,club_id,reason,status,type,created_time,invite_id,falg_admin,file_id from t_user_msg where 1 and (team_id in (3212) and app_id is not null) or (invite_id=12395 or applicant_id=12395) order by created_time desc limit 0,10; | 1219921665 |

从慢查询slow_log可以看到,执行时间2s,扫描了443117行,只返回了9行,这是不合理的。

SQL分析

#原始SQL,频繁访问的接口,目前执行时间2s。
select id,team_id,reason,status,type,created_time,invite_id,falg_admin,file_id from t_user_msg where 1 and (team_id in (3212) and app_id is not null) or (invite_id=12395 or app_id=12395) order by created_time desc limit 0,10;

#执行计划
+----+-------------+--------------+-------+---------------------------------+------------+---------+------+------+-------------+
| id | select_type | table        | type  | possible_keys                   | key        | key_len | ref  | rows | Extra       |
+----+-------------+--------------+-------+---------------------------------+------------+---------+------+------+-------------+
|  1 | SIMPLE      | t_user_msg | index | invite_id,app_id,team_id | created_time | 5       | NULL |   10 | Using where |
+----+-------------+--------------+-------+---------------------------------+------------+---------+------+------+-------------+
1 row in set (0.00 sec)

从执行计划可以看到,表上有单列索引invite_id,app_id,team_id,created_time,走的是create_time的索引,而且type=index索引全扫描,因为create_time没有出现在where条件后,只出现在order by后面,只能是type=index,这也预示着表数据量越大该SQL越慢,我们期望是走三个单列索引invite_id,app_id,team_id,然后type=index_merge操作。

按照常规思路,对于OR条件拆分两部分,分别进行分析。

select id, ……. from t_user_msg where 1 and  **(team_id in (3212) and app_id is not null)** order by created_time desc limit 0,10;

从执行计划看走的是team_id的索引,没有问题。

| id | select_type | table        | type | possible_keys        | key     | key_len | ref   | rows | Extra                       |
+----+-------------+--------------+------+----------------------+---------+---------+-------+------+-----------------------------+
|  1 | SIMPLE      | t_user_msg | ref  | app_id,team_id | team_id | 8       | const |   30 | Using where; Using filesort |

再看另外一个sql语句:

select id, ……. from t_user_msg where 1 and  **(invite_id=12395 or app_id=12395)** order by created_time desc limit 0,10;

从执行计划上看,分别走的是invite_id,app_id的单列索引,同时做了index_merge合并操作,也没有问题。

| id | select_type | table        | type        | possible_keys           | key                     | key_len | ref  | rows | Extra                                                             |
+----+-------------+--------------+-------------+-------------------------+-------------------------+---------+------+------+-------------------------------------------------------------------+
|  1 | SIMPLE      | t_user_msg | index_merge | invite_id,app_id | invite_id,app_id | 9,9     | NULL |    2 | Using union(invite_id,app_id); Using where; Using filesort |

通过上面的分析,第一部分SQL走的执行计划走team_id索引没问题,第二部分SQL分别走invite_id,app_id索引并且index_merge也没问题,为什么两部分SQL进行OR关联之后走create_time的单列索引呢,不应该是三个单列索引的index_merge吗?

index_merge默认是在优化器选项是开启的,主要是将多个范围扫描的结果集合并成一个,可以通过变量查看。

mysql >select @@optimizer_switch;
| index_merge=on,index_merge_union=on,index_merge_sort_union=on,index_merge_intersection=on,

其他三个字段都传入的是具体的值,而且都走了相应的索引,只能怀疑app_id is not null这个条件影响了CBO对最终执行计划的选择,去掉这个条件来看执行计划,竟然走了三个单列索引且type=index_merge,那下面只要搞定app_id is not null这个条件就OK了吧。

| id | select_type | table        | type        | possible_keys                   | key                             | key_len | ref  | rows | Extra                                                                     |
+----+-------------+--------------+-------------+---------------------------------+---------------------------------+---------+------+------+---------------------------------------------------------------------------+
|  1 | SIMPLE      | t_user_msg | index_merge | invite_id,app_id,teadm_id | team_id,invite_id,app_id | 8,9,9   | NULL |   32 | Using union(team_id,invite_id,app_id); Using where; Using filesort |

SQL改写

通过上面分析得知,条件app_id is not null影响了CBO的选择,下面进行改造。

改写优化1

根据SQL开发规范改写,将OR改写成Union All方式即可,最终的SQL如下:

select id, ……. from (
select id, ……. from t_user_msg where **1 and (club_id in (5821) and applicant_id is not null)**
        **union all** select id, ……. from t_user_msg where **1 and invitee_id='146737'**
        **union all** select id, ……. from  t_user_msg where **1 and app_id='146737'**
       ) as a order by created_time desc limit 0,10;

一般情况下,Java代码和SQL是分开的,SQL是配置在xml文件中,根据业务需求,除了team_id是必填,其他两个都是可选的,所以这种改写虽然能提高SQL执行效率,但不适合这种业务场景。

改写优化2

app_id is not null 改写为IFNULL(app_id, 0) >0),最终的SQL为:

select id,team_id,reason,status,type,created_time,invite_id,falg_admin,file_id from t_user_msg where 1 and (team_id in (3212) and **IFNULL(app_id, 0) >0)**) or (invite_id=12395 or app_id=12395) order by created_time desc limit 0,10;

改写优化3

将字段app_id bigint(20) DEFAULT NULL,变更为app_id bigint(20) NOT NULL DEFAULT 0,同时更新将app_id is null的时候全部更新成0,就可以将条件app_id is not null 转换为app_id > 0,最终的SQL为:

select id,team_id,reason,status,type,created_at,invite_id,falg_admin,file_id from t_user_msg where 1 and (team_id in (3212) and **app_id > 0)**) or (invite_id=12395 or app_id=12395) order by created_time desc limit 0,10;

从执行计划看,两种改写优化方式都走三个单列索引,执行时间从2s降低至10ms,线上采用的是优化1的方式,如果一开始能遵循MySQL开发规范就就会避免问题的发生。

总结

上面介绍了SQL规范性检查,表结构检查,索引检查以及通过SQL改写来优化查询,在编写代码的过程,如果能提前做这些规范性检查,评估出自己认为理想的执行计划,然后通过explain解析出MySQL CBO的执行计划,两者做对比分析差异,弄清楚自己的选择和CBO的不同,不但能够编写高质量的SQL,同时也能清楚CBO的工作原理。

文章持续更新,可以微信搜一搜「 三太子敖丙 」第一时间阅读,回复【资料】有我准备的一线大厂面试资料和简历模板,本文 GitHubhttps://github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。
查看原文

赞 39 收藏 29 评论 1

敖丙 发布了文章 · 2020-11-09

为什么阿里巴巴不建议MySQL使用Text类型?

有情怀,有干货,微信搜索【三太子敖丙】关注这个不一样的程序员。

本文 GitHubhttps://github.com/JavaFamily 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

前言

众所周知,MySQL广泛应用于互联网的OLTP(联机事务处理过程)业务系统中,在大厂开发规范中,经常会看到一条"不建议使用text大字段类型”。

下面就从text类型的存储结构,引发的问题解释下为什么不建议使用text类型,以及Text改造的建议方法。

背景

写log表导致DML慢

问题描述

某歪有一个业务系统,使用RDS for MySQL 5.7的高可用版本,配置long_query_time=1s,添加慢查询告警,我第一反应就是某歪又乱点了。

我通过监控看CPU, QPS,TPS等指标不是很高,最近刚好双十一全站都在做营销活动,用户量稍微有所增加。 某歪反馈有些原本不慢的接口变的很慢,影响了正常的业务,需要做一下troubleshooting。

问题分析

我从慢查询告警,可以看到有一些insert和update语句比较慢,同时告警时段的监控,发现IOPS很高,达到了70MB/s左右,由于RDS的CloundDBA功能不可用,又没有audit log功能,troubleshooting比较困难,硬着头皮只能分析binlog了。

配置了max_binlog_size =512MB,在IOPS高的时段里,看下binlog的生成情况。

需要分析为什么binlog写这么快,最有可能原因就是insert into request_log表上有text类型,request_log表结构如下(demo)

CREATE TABLE request_log (`
 `id bigint(20) NOT NULL AUTO_INCREMENT,`
 `log text,`    
 `created_at datetime NOT NULL,`
 `status tinyint(4) NOT NULL,`
 `method varchar(10) DEFAULT NULL,`
 `url varchar(50) DEFAULT NULL,`
 `update_at datetime DEFAULT NULL,`
 `running_time tinyint(4) DEFAULT '0',`
 `user_id bigint(20) DEFAULT NULL,`
 `type varchar(50) DEFAULT NULL,`
 `PRIMARY KEY (id)`
`) ENGINE=InnoDB AUTO_INCREMENT=4229611 DEFAULT CHARSET=utf8` 

分析binlog:

$ mysqlbinlog --no-defaults -v -v --base64-output=DECODE-ROWS mysql-bin.000539|egrep "insert into request_log"

满屏幕都是看不清的内容,翻了半天没翻完。

基本上已经确定是写入request_log的log字段引起的,导致binlog_cache频繁的flush,以及binlog过度切换,导致IOPS过高,影响了其他正常的DML操作。

问题解决

跟开发同学沟通后,计划在下一个版本修复这个问题,不再将request信息写入表中,写入到本地日志文件,通过filebeat抽取到es进行查询,如果只是为了查看日志也可以接入grayLog等日志工具,没必要写入数据库。

文章最后我还会介绍几个MySQL 我踩过Text相关的坑,这介绍坑之前我先介绍下MySQLText类型。

MySQL中的Text

Text类型

text是一个能够存储大量的数据的大对象,有四种类型:TINYTEXT, TEXT, MEDIUMTEXT,LONGTEXT,不同类型存储的值范围不同,如下所示

Data TypeStorage Required
TINYTEXTL + 1 bytes, where L < 2**8
TEXTL + 2 bytes, where L < 2**16
MEDIUMTEXTL + 3 bytes, where L < 2**24
LONGTEXTL + 4 bytes, where L < 2**32

其中L表是text类型中存储的实际长度的字节数。可以计算出TEXT类型最大存储长度2**16-1 = 65535 Bytes。

InnoDB数据页

Innodb数据页由以下7个部分组成:

内容占用大小说明
File Header38Bytes数据文件头
Page Header56 Bytes数据页头
Infimun 和 Supermum Records伪记录
User Records用户数据
Free Space空闲空间:内部是链表结构,记录被delete后,会加入到free_lru链表
Page Dictionary页数据字典:存储记录的相对位置记录,也称为Slot,内部是一个稀疏目录
File Trailer8Bytes文件尾部:为了检测页是否已经完整个的写入磁盘

说明:File Trailer只有一个FiL_Page_end_lsn部分,占用8字节,前4字节代表该页的checksum值,最后4字节和File Header中的FIL_PAGE_LSN,一个页是否发生了Corrupt,是通过File Trailer部分进行检测,而该部分的检测会有一定的开销,用户可以通过参数innodb_checksums开启或关闭这个页完整性的检测。

从MySQL 5.6开始默认的表存储引擎是InnoDB,它是面向ROW存储的,每个page(default page size = 16KB),存储的行记录也是有规定的,最多允许存储16K/2 - 200 = 7992行。

InnoDB的行格式

Innodb支持四种行格式:

行格式Compact存储特性增强的变长列存储支持大前缀索引支持压缩支持表空间类型
REDUNDANTNoNoNoNosystem, file-per-table, general
COMPACTYesNoNoNosystem, file-per-table, general
DYNAMICYesYesYesNosystem, file-per-table, general
COMPRESSEDYesYesYesYesfile-per-table, general

由于Dynamic是Compact变异而来,结构大同而已,现在默认都是Dynamic格式;COMPRESSED主要是对表和索引数据进行压缩,一般适用于使用率低的归档,备份类的需求,主要介绍下REDUNDANT和COMPACT行格式。

Redundant行格式

这种格式为了兼容旧版本MySQL。

行记录格式:

Variable-length offset listrecord_headercol1_valuecol2_value…….text_value
字段长度偏移列表记录头信息,占48字节列1数据列2数据…….Text列指针数据

具有以下特点:

  • 存储变长列的前768 Bytes在索引记录中,剩余的存储在overflow page中,对于固定长度且超过768 Bytes会被当做变长字段存储在off-page中。
  • 索引页中的每条记录包含一个6 Bytes的头部,用于链接记录用于行锁。
  • 聚簇索引的记录包含用户定义的所有列。另外还有一个6字节的事务ID(DB_TRX_ID)和一个7字节长度的回滚段指针(Roll pointer)列。
  • 如果创建表没有显示指定主键,每个聚簇索引行还包括一个6字节的行ID(row ID)字段。
  • 每个二级索引记录包含了所有定义的主键索引列。
  • 一条记录包含一个指针来指向这条记录的每个列,如果一条记录的列的总长度小于128字节,这个指针占用1个字节,否则2个字节。这个指针数组称为记录目录(record directory)。指针指向的区域是这条记录的数据部分。
  • 固定长度的字符字段比如CHAR(10)通过固定长度的格式存储,尾部填充空格。
  • 固定长度字段长度大于或者等于768字节将被编码成变长的字段,存储在off-page中。
  • 一个SQL的NULL值存储一个字节或者两个字节在记录目录(record dirictoty)。对于变长字段null值在数据区域占0个字节。对于固定长度的字段,依然存储固定长度在数据部分,为null值保留固定长度空间允许列从null值更新为非空值而不会引起索引的分裂。
  • 对varchar类型,Redundant行记录格式同样不占用任何存储空间,而CHAR类型的NULL值需要占用空间。

其中变长类型是通过长度 + 数据的方式存储,不同类型长度是从1到4个字节(L+1 到 L + 4),对于TEXT类型的值需要L Bytes存储value,同时需要2个字节存储value的长度。同时Innodb最大行长度规定为65535 Bytes,对于Text类型,只保存9到12字节的指针,数据单独存在overflow page中。

Compact行格式

这种行格式比redundant格式减少了存储空间作为代价,但是会增加某些操作的CPU开销。如果系统workload是受缓存命中率和磁盘速度限制,compact行格式可能更快。如果你的工作负载受CPU速度限制,compact行格式可能更慢,Compact 行格式被所有file format所支持。

行记录格式:

Variable-length field length listNULL标志位record_headercol1_valuecol2_value…….text_value
变长字段长度列表记录头信息-列1数据列2数据…….Text列指针数据

Compact首部是一个非NULL变长字段长度的列表,并且是按列的顺序逆序放置的,若列的长度小于255字节,用1字节表示;若大于255个字节,用2字节表示。变长字段最大不可以超过2字节,这是因为MySQL数据库中varchar类型最大长度限制为65535,变长字段之后的第二个部分是NULL标志位,表示该行数据是否有NULL值。有则用1表示,该部分所占的字节应该为1字节。

所以在创建表的时候,尽量使用NOT NULL DEFAULT '',如果表中列存储大量的NULL值,一方面占用空间,另一个方面影响索引列的稳定性。

具有以下特点:

  • 索引的每条记录包含一个5个字节的头部,头部前面可以有一个可变长度的头部。这个头部用来将相关连的记录链接在一起,也用于行锁。
  • 记录头部的变长部分包含了一个表示null 值的位向量(bit vector)。如果索引中可以为null的字段数量为N,这个位向量包含 N/8 向上取整的字节数。比例如果有9-16个字段可以为NULL值,这个位向量使用两个字节。为NULL的列不占用空间,只占用这个位向量中的位。头部的变长部分还包含了变长字段的长度。每个长度占用一个或者2个字节,这取决了字段的最大长度。如果所有列都可以为null 并且制定了固定长度,记录头部就没有变长部分。
  • 对每个不为NULL的变长字段,记录头包含了一个字节或者两个字节的字段长度。只有当字段存储在外部的溢出区域或者字段最大长度超过255字节并且实际长度超过127个字节的时候会使用2个字节的记录头部。对应外部存储的字段,两个字节的长度指明内部存储部分的长度加上指向外部存储部分的20个字节的指针。内部部分是768字节,因此这个长度值为 768+20, 20个字节的指针存储了这个字段的真实长度。
  • NULL不占该部分任何空间,即NULL除了占用NULL标志位,实际存储不占任何空间。
  • 记录头部跟着非空字段的数据部分。
  • 聚簇索引的记录包含了所以用户定于的字段。另外还有一个6字节的事务ID列和一个7字节的回滚段指针。
  • 如果没有定于主键索引,则聚簇索引还包括一个6字节的Row ID列。
  • 每个辅助索引记录包含为群集索引键定义的不在辅助索引中的所有主键列。如果任何一个主键列是可变长度的,那么每个辅助索引的记录头都有一个可变长度的部分来记录它们的长度,即使辅助索引是在固定长度的列上定义的。
  • 固定长度的字符字段比如CHAR(10)通过固定长度的格式存储,尾部填充空格。
  • 对于变长的字符集,比如uft8mb3和utf8mb4, InnoDB试图用N字节来存储 CHAR(N)。如果CHAR(N)列的值的长度超过N字节,列后面的空格减少到最小值。CHAR(N)列值的最大长度是最大字符编码数 x N。比如utf8mb4字符集的最长编码为4,则列的最长字节数是 4*N。

Text类型引发的问题

插入text字段导致报错

创建测试表

[root@barret] [test]>create table user(id bigint not null primary key auto_increment, 
  -> name varchar(20) not null default '' comment '姓名', 
  -> age tinyint not null default 0 comment 'age', 
  -> gender char(1) not null default 'M' comment '性别',
  -> info text not null comment '用户信息',
  -> create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  -> update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间'
  -> );
Query OK, 0 rows affected (0.04 sec)

插入测试数据

root@barret] [test]>insert into user(name,age,gender,info) values('moon', 34, 'M', repeat('a',1024*1024*3));
ERROR 1406 (22001): Data too long for column 'info' at row 1
[root@barret] [test]>insert into user(name,age,gender,info) values('sky', 35, 'M', repeat('b',1024*1024*5));
ERROR 1301 (HY000): Result of repeat() was larger than max_allowed_packet (4194304) - truncated

错误分析

[root@barret] [test]>select @@max_allowed_packet;
+----------------------+
| @@max_allowed_packet |
+----------------------+
|       4194304 |
+----------------------+
1 row in set (0.00 sec)

max_allowed_packet控制communication buffer最大尺寸,当发送的数据包大小超过该值就会报错,我们都知道,MySQL包括Server层和存储引擎,它们之间遵循2PC协议,Server层主要处理用户的请求:连接请求—>SQL语法分析—>语义检查—>生成执行计划—>执行计划—>fetch data;存储引擎层主要存储数据,提供数据读写接口。

max_allowed_packet=4M,当第一条insert repeat('a',1024*1024*3),数据包Server执行SQL发送数据包到InnoDB层的时候,检查数据包大小没有超过限制4M,在InnoDB写数据时,发现超过了Text的限制导致报错。第二条insert的数据包大小超过限制4M,Server检测不通过报错。

引用AWS RDS参数组中该参数的描述

max_allowed_packet: This value by default is small, to catch large (possibly incorrect) packets. Must be increased if using large TEXT columns or long strings. As big as largest BLOB.

增加该参数的大小可以缓解报错,但是不能彻底的解决问题。

RDS实例被锁定

背景描述

公司每个月都会做一些营销活动,有个服务apush活动推送,单独部署在高可用版的RDS for MySQL 5.7,配置是4C8G 150G磁盘,数据库里也就4张表,晚上22:00下班走的时候,rds实例数据使用了50G空间,第二天早晨9:30在地铁上收到钉钉告警短信,提示push服务rds实例由于disk is full被locked with —read-only,开发也反馈,应用日志报了一堆MySQL error。

问题分析

通过DMS登录到数据库,看一下那个表最大,发现有张表push_log占用了100G+,看了下表结构,里面有两个text字段。

request text default '' comment '请求信息',
response text default '' comment '响应信息'
mysql>show  table status like 'push_log';

发现Avg_row_length基本都在150KB左右,Rows = 78w,表的大小约为780000*150KB/1024/1024 = 111.5G。

通过主键update也很慢

insert into user(name,age,gender,info) values('thooo', 35, 'M', repeat('c',65535);
insert into user(name,age,gender,info) values('thooo11', 35, 'M', repeat('d',65535);
insert into user(name,age,gender,info) select name,age,gender,info from user;
Query OK, 6144 rows affected (5.62 sec)
Records: 6144  Duplicates: 0  Warnings: 0                                        
[root@barret] [test]>select count(*) from user;
+----------+
| count(*) |
+----------+
|    24576 |
+----------+
1 row in set (0.05 sec)

做update操作并跟踪。

mysql> set profiling = 1;
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> update user set info = repeat('f',65535) where id = 11;
Query OK, 1 row affected (0.28 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> show profiles;
+----------+------------+--------------------------------------------------------+
| Query_ID | Duration   | Query                                                  |
+----------+------------+--------------------------------------------------------+
|        1 | 0.27874125 | update user set info = repeat('f',65535) where id = 11 |
+----------+------------+--------------------------------------------------------+
1 row in set, 1 warning (0.00 sec)

mysql> show profile cpu,block io for query 1;  
+----------------------+----------+----------+------------+--------------+---------------+
| Status               | Duration | CPU_user | CPU_system | Block_ops_in | Block_ops_out |
+----------------------+----------+----------+------------+--------------+---------------+
| starting             | 0.000124 | 0.000088 |   0.000035 |            0 |             0 |
| checking permissions | 0.000021 | 0.000014 |   0.000006 |            0 |             0 |
| Opening tables       | 0.000038 | 0.000026 |   0.000011 |            0 |             0 |
| init                 | 0.000067 | 0.000049 |   0.000020 |            0 |             0 |
| System lock          | 0.000076 | 0.000054 |   0.000021 |            0 |             0 |
| updating             | 0.244906 | 0.000000 |   0.015382 |            0 |         16392 |
| end                  | 0.000036 | 0.000000 |   0.000034 |            0 |             0 |
| query end            | 0.033040 | 0.000000 |   0.000393 |            0 |           136 |
| closing tables       | 0.000046 | 0.000000 |   0.000043 |            0 |             0 |
| freeing items        | 0.000298 | 0.000000 |   0.000053 |            0 |             0 |
| cleaning up          | 0.000092 | 0.000000 |   0.000092 |            0 |             0 |
+----------------------+----------+----------+------------+--------------+---------------+
11 rows in set, 1 warning (0.00 sec)

可以看到主要耗时在updating这一步,IO输出次数16392次,在并发的表上通过id做update,也会变得很慢。

group_concat也会导致查询报错

在业务开发当中,经常有类似这样的需求,需要根据每个省份可以定点医保单位名称,通常实现如下:

select group_concat(dru_name) from t_drugstore group by province;

其中内置group_concat返回一个聚合的string,最大长度由参数group_concat_max_len(Maximum allowed result length in bytes for the GROUP_CONCAT())决定,默认是1024,一般都太短了,开发要求改长一点,例如1024000。

当group_concat返回的结果集的大小超过max_allowed_packet限制的时候,程序会报错,这一点要额外注意。

MySQL内置的log表

MySQL中的日志表mysql.general_log和mysql.slow_log,如果开启审计audit功能,同时log_output=TABLE,就会有mysql.audit_log表,结构跟mysql.general_log大同小异。

分别看一下他们的表结构

CREATE TABLE `general_log` (
  `event_time` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  `user_host` mediumtext NOT NULL,
  `thread_id` bigint(21) unsigned NOT NULL,
  `server_id` int(10) unsigned NOT NULL,
  `command_type` varchar(64) NOT NULL,
  `argument` mediumblob NOT NULL
) ENGINE=CSV DEFAULT CHARSET=utf8 COMMENT='General log'
CREATE TABLE `slow_log` (
  `start_time` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  `user_host` mediumtext NOT NULL,
  `query_time` time(6) NOT NULL,
  `lock_time` time(6) NOT NULL,
  `rows_sent` int(11) NOT NULL,
  `rows_examined` int(11) NOT NULL,
  `db` varchar(512) NOT NULL,
  `last_insert_id` int(11) NOT NULL,
  `insert_id` int(11) NOT NULL,
  `server_id` int(10) unsigned NOT NULL,
  `sql_text` mediumblob NOT NULL,
  `thread_id` bigint(21) unsigned NOT NULL
) ENGINE=CSV DEFAULT CHARSET=utf8 COMMENT='Slow log'

mysql.general_log记录的是经过MySQL Server处理的所有的SQL,包括后端和用户的,insert比较频繁,同时argument mediumblob NOT NULL,对MySQL Server性能有影响的,一般我们在dev环境为了跟踪排查问题,可以开启general_log,Production环境禁止开启general_log,可以开启audit_log,它是在general_log的基础上做了一些filter,比如我只需要业务账号发起的所有的SQL,这个很有用的,很多时候需要分析某一段时间内哪个SQL的QPS,TPS比较高。

mysql.slow_log记录的是执行超过long_query_time的所有SQL,如果遵循MySQL开发规范,slow query不会太多,但是开启了log_queries_not_using_indexes=ON就会有好多full table scan的SQL被记录,这时slow_log表会很大,对于RDS来说,一般只保留一天的数据,在频繁insert into slow_log的时候,做truncate table slow_log去清理slow_log会导致MDL,影响MySQL稳定性。

建议将log_output=FILE,开启slow_log, audit_log,这样就会将slow_log,audit_log写入文件,通过Go API处理这些文件将数据写入分布式列式数据库clickhouse中做统计分析。

Text改造建议

使用es存储

在MySQL中,一般log表会存储text类型保存request或response类的数据,用于接口调用失败时去手动排查问题,使用频繁的很低。可以考虑写入本地log file,通过filebeat抽取到es中,按天索引,根据数据保留策略进行清理。

使用对象存储

有些业务场景表用到TEXT,BLOB类型,存储的一些图片信息,比如商品的图片,更新频率比较低,可以考虑使用对象存储,例如阿里云的OSS,AWS的S3都可以,能够方便且高效的实现这类需求。

总结

由于MySQL是单进程多线程模型,一个SQL语句无法利用多个cpu core去执行,这也就决定了MySQL比较适合OLTP(特点:大量用户访问、逻辑读,索引扫描,返回少量数据,SQL简单)业务系统,同时要针对MySQL去制定一些建模规范和开发规范,尽量避免使用Text类型,它不但消耗大量的网络和IO带宽,同时在该表上的DML操作都会变得很慢。

另外建议将复杂的统计分析类的SQL,建议迁移到实时数仓OLAP中,例如目前使用比较多的clickhouse,里云的ADB,AWS的Redshift都可以,做到OLTP和OLAP类业务SQL分离,保证业务系统的稳定性。

好啦以上就是本期的全部内容了,我是敖丙,你知道的越多,你不知道的越多,我们下期见!

絮叨

敖丙把自己的面试文章整理成了一本电子书,共 1630页!

干货满满,字字精髓。目录如下,还有我复习时总结的面试题以及简历模板,现在免费送给大家。

链接:https://pan.baidu.com/s/1ZQEKJBgtYle3v-1LimcSwg 密码:wjk6

我是敖丙,你知道的越多,你不知道的越多,感谢各位人才的:点赞收藏评论,我们下期见!


文章持续更新,可以微信搜一搜「 三太子敖丙 」第一时间阅读,回复【资料】有我准备的一线大厂面试资料和简历模板,本文 GitHubhttps://github.com/JavaFamily 已经收录,有大厂面试完整考点,欢迎Star。
查看原文

赞 21 收藏 10 评论 3

敖丙 发布了文章 · 2020-10-20

程序人生|从网瘾少年到微软、BAT、字节offer收割机逆袭之路

引言

今天给大家分享一个我的读者的故事,这个故事很长,从游戏boy到offer收割机,从富士康到百度再到微软,国内知名大厂的公司他都拿了一遍offer。

这当中有太多心酸和努力,在他的身上我也能看到一些自己的影子,希望大家可以从他的文章里有所收获,有所感悟。

话不多说,我们来听他的故事。

正文

国庆节的第一天,自习室里已经没有什么人了。窗外,西安的秋天飘一点点雨,坐在电脑前心情十分平静。想在这个难得的闲暇里,想起记录一下自己这些年的经历,也是给自己留一点以后可以回忆的故事。

个人2014年入学,武汉某大学计科相关专业,学科评估200名开外。

大一上学期一门专业课差点挂科,直接奠定了无法保研的局面,开始浑浑噩噩混了两年。除了高数上下,其他能逃的课基本都用来打英雄联盟了。

16年升大三的暑假,一个偶然的机会看到隔壁院的师兄发在群里的一条实习招聘,是武汉富士康招聘软件测试实习生。

暑期岗位,能签实习证明,有班车来学校接送,一天还有220工资。我觉得这是个很好的机会,起码富士康这个厂也算有些名气,能赚个实习经历还有点工资。

我向师兄报了名,简历里面特别注明了大学C语言92分,班级第二。简历通过的还算顺利,也没有面试,直接就让去了。

当时负责的任务主要是Windows 10 SP1的多国版本测试任务,跟我以为的进去的写代码相差甚远,就是个黑盒测试吧,或者再说直白些,就是点点点的无脑操作没什么技术含量。

不过由于是对接外企,所以任务都是英文下达的,有时候翻译还是得花点功夫,英文能力倒是得到些许锻炼。测试需要自己组装机器,选择各型号的cpu和显卡等配件。

因为是第一份实习,我学习的非常认真,直到现在我仍然能够闭上眼睛,清晰完整的回忆出一台整机的拼装全过程。

CPU的引脚,内存条的金手指和各式各样的SATA线束是那段时间接触最多的东西。

机器点亮后就开始做DASH(从服务器上下载测试版本的系统并安装),然后是激活系统开始对照着测试用例展开测试。

差不多两个星期之后,常规的操作已经比较熟练了,任务也显得逐渐无聊了起来。由于DASH的过程很漫长,我经常会在三楼到四楼的楼梯口望着窗外的太阳,不知道是哪个有趣的同事在窗台用矿泉水瓶养了一个小叶子,我看着这抹绿色总是很舒服。

富士康的生活很规律,八点半到工位,五点半出公司,日复一日的装配着各种方案让我在想,以后我要从事这种枯燥但是轻松的工作吗? 想了想还是写代码做需求比较有意思。

由于本科学校不太好找工作,而我自认代码能力还可以,所以我决定通过考研来获得一个更好的教育背景或者说,一个让公司能够看上的背书。

大三暑假正值备考,跟我关系不错的老师给了我一个机会,说有个师兄在美团,想把我的简历推给他。我当然很激动,花了一下午的时间准备了一份简历:xx学校,计算机专业,主语言java,曾经做过xx校园app的后端功能以及一个在线OJ评测网站... ...

简历发给了老师,一天,两天,一个星期过去了,老师说那边认为简历还是太单薄了,不能发起面试。

那天我感到一阵失落,我以为起码能有个面试机会,结果却是简历都过不了。

我对自己的代码水平还是比较有信心,但恰恰是这种信心带给了我更大的失落。我生气的跟舍友说,以后绝对不去美团,他求我去我都不去。这当然是一个大话,只是孩子心气地不肯承认罢了。

考研历时五个半月,还算顺利地通过了初试复试,来到了西安的一所高校开始了研究生阶段的学习。

那时候有同学说,一个叫字节跳动的公司能开很高的工资,不过对算法题的要求很高。

这也是我第一次听说字节跳动,研究生的课和本科其实没有太大的区别,至少对我来说都是不怎么听的进去的(当然也有部分非常优秀的课程,这是后话了)。

但我在课上发呆的时候,慢慢地却不会再去想我要怎么操作我的英雄站在兵线前面干掉对面的玩家,LOL至今都没时间再玩了,时不时看看比赛倒也对青春有个交代。

<img  style="border-radius: 0.3125em;
box-shadow: 0 2px 4px 0 rgba(34,36,38,.12),0 2px 10px 0 rgba(34,36,38,.08);" 
data-original="https://m.qpic.cn/psc?/V12w6YIp4GQ5pr/bqQfVz5yrrGYSXMvKr.cqXpLZKuvoQw5kIZ6DG1L4Gst9rS7q3SpJaiYiZE.lzn1t*nvJ7he89lfvt*BtlKvuJGoGUgVsPhWzNMYR73Va9Y!/b&bo=gAc4BAAAAAABB5s!&rf=viewer_4">

研究生阶段无聊的时候,一般我会打开牛客网,在上面做几道题目,看着各种计算机网络和操作系统的知识,总觉得自己永远也学不完了。在浏览讨论贴的过程中,我渐渐发现网友会去做一些算法题的训练,一般在leetcode上。我也随之注册了这个网站,全英文的界面让我觉得很有范。

没多久我就决定要开始我的刷题之路,我仍然记得第一题好像是求两数之和,其实这个题目我在备考时的九度OJ上做过,感觉应该难度不算太大,磨了一些边界之后,当一个绿色的Accept出现在眼前,我觉得这种感觉就像是我用XX√√√完成了一局BO5晋级赛。

于是我的生活变得更简单了,能逃的课就在寝室和图书馆刷LEETCODE,不能逃的课就在课上刷LeetCode。我尽量保持在一天6+的题量,有时候会做到10+,我喜欢看到登录界面上的绿点连成一片的样子。

不过由于课程作业和考试原因,有时候也会中断好一会。我非常清楚的记得在一个下雪的午后,我完成了在Leetcode上的第一百个Accept,那一题调了两个小时,是LRU置换算法的实现。我激动的发了一条朋友圈,拍下100/1300的标记。

我觉得我好像会离字节这样的公司近一点了,在刷题的过程中我也会穿插着学习一些计算机网络的东西,主要是为了应付面试。搜索的资料很杂,大部分来自博客和B站。

包括在刷题的过程中我看的算法视频也是在B站搜罗的盗版,主讲人是左程云左神,也是我心目中永远的真神。那一版的视频左神的桌面还是齐天大圣飘着红色披风的背影,后来在北京时期我补上了欠左神的正版课,这也是后话了。

研一暑期我也开始投一些公司的实习,我惊奇的发现,大部分的笔试都拦不住我了,反正基本没有因为笔试挂过的公司,也拿到了一个不错的机会,但因为一些原因未能成功去实习。

后来看着同学们都出去了外面的公司实习,我的心里又开始痒痒了,打开牛客网,翻查着有限的实习信息,看到一个爱奇艺的招聘机会。

一轮大概50分钟左右的面试,主要是针对OJ项目,后面问到了Java异常机制,泛型的实现,FutureTask的实现思路,以及一些线程池的问题,线程池这个我没用过所以就说不会。

当天晚上八点,面试官问我公司在北京,能不能来实习,我想都没想就说地点绝对不是问题,于是我定了第三天最早的一班高铁,花了一天的时间收拾东西,其实我不是为了带走什么,只是要把它们放在朋友那里。

我带走的只有一个背包,里面放了两天的换洗衣物,我的各种证件,一小盒谷维素(改善失眠)和一本深入理解Java虚拟机。我觉得做Java的人,这个东西带在身上就很有安全感,有信仰陪着我,虽然我从来也没有翻开过。

房子是在高铁上租的,转租的人是个字节跳动的运营,地点就在人大旁边,离爱创大厦很近。

我还记得当时我的leader在做完新手教程后,就过来帮我搬电脑,带我整理工位,简单的交代了一下之后,就慢慢的开始了开发流程。

第一次进入大公司实习,第一次来到首都北京,红绿灯前等待的全部都是清一色年轻的面孔,有那么一个瞬间我恍惚地看到,斑马线变成了律动地音符,而这个城市年轻人的心跳就是它的节拍。

我喜欢早上骑着师兄传给我的美利达,再放一首Young For You, 我觉得我就是属于这个时代的年轻的人儿。

作为新人,要学习的东西很多,一般我会十点半以后下班。而且我喜欢前紧后松,所以会把心中的工期排的靠前一点。

我惊讶于自己学了一年没学懂的Spring框架在公司捣把捣把竟然就能上手一些项目了,这种学习的速度是我从来没有想过的。

周末我也会在公司赶需求或者自我学习,累了就去对面吃一个麦当劳甜筒,有时候也会去咖啡厅坐会。那通常是因为我解决了一个困扰很久的Bug.虽然在爱奇艺的生活很充实,但是心中还是有一份大厂梦。

19年的国庆,整整八天我都呆在公司看着各种字节跳动的面经和算法题,只要有一题我觉得我可能不能100%实现,我都会上Leetcode立马把它AC掉。

行内的朋友可能知道这是怎样的痛苦,因为一道题有思路和真正稳稳地实现它,中间的差距实在太大了。

7号的晚上,我坐在电脑前,关上所有的页面,打开Eclipse,花一分钟或者几十秒撸了一发快排,测试用例直接写在main里,一把过,我关上了电脑回家。

这是我自己的习惯,每逢大战,我都会在最后以一个快排收尾,因为当年就是这个代码断了我的保研路。开发的时候我用的更多的是IDEA,但是写算法题我只用Eclipse, 因为算法题对依赖环境的要求很低,基本上有个JDK就足够。

而我是个恋旧的人,所以我把自己最擅长的方面,留给我的老朋友Eclipse。

结果并不像过程一般顺利,也不是每个幸运都会眷顾努力。

12号一天两个公司面试,分别在早上10点和晚上7点开始。紫金数码园成为了我永远的痛,我记得他们问的都是比较深入的问题,诸如TCP,UDP能不能绑在同一个端口,Java的线程底层是如何实现的,TCP和IP的详细报文结构,报头,进程切换的上下文到底包括什么,哪些寄存器,CFS算法如何实现的等等。

最后我实在烦了,我说我就不会操作系统,他又问了个字符串匹配算法,我给他详细的讲了KMP的实现,然后他让写个树的深度遍历。我火一下子上来了,我觉得你可以挂我,但是不要用这种简单的东西来拖一下面试时间让我体面的离开。

我就说:这个东西太简单了,我不想写,你可以问个难点的。

他让我写Linux的定时器算法,我想了很久,没想出来,事实上我都没听过这个东西,自己设计了一个类似哈希表的结构希望来存放这些定时任务。他表示摇头,我知道今天就到这里了。

现在回想起来,我并没给到面试官足够的尊重,多少年少轻狂了点。

直到最近这个月,我开始认真的阅读深入理解Linux内核,才明白当时我到底错在哪(当然时间轮算法是我在第二天就去看了的)。

字节的面试是在晚上,七天的努力好像什么效果也没有,因为我准备的面经基本一点也没被问到。

走在中关村大街上,我觉得今天有点格外的冷,只能把耳机的声音调的再大一点,宝石老舅的Disco让我的情绪稍微得到了一些缓和。

细心的朋友可能发现,那天的面试有两场,这个机会说来很巧,我某次跟同学提到我想换个更大的厂,她说百度给她打过电话面试,是个私人号码,她自己已经有公司了所以不想面试。

我意识到这是个机会,招聘信息里面大部分是邮箱或者工作电话,其实联系的成功率并不高,但是这种私人电话基本是点对点,中间不存在邮箱这种轮询或者工作号码背后的一层Nginx。

我直接把号码拨过去,询问对面是不是需要招收实习生,表明了自己的学校和来意,希望对面能安排一个面试机会。

答复是肯定的,我特意要求在跟字节同一天,这样子免得我需要频繁请假,所有才有了一天之内字节和百度的两次面试。

来北京的第一个星期我就去拜访过百度大厦,西二旗地铁的一侧静静躺着一个熊掌,当我近距离看到这个标志的时候心中翻起的是澎湃和向往。

不过这次的面试在百度科技园,如果说大厦给我的感觉是大气,那么科技园就是真正的气势恢宏。

回字型的无限大标志由七栋大楼构成,K2和K1的连廊接在三楼,直接跨过了一个双向车道。面试的整体过程比较顺利,给我的面试体验也非常好,面试官会针对我简历上的技术栈由浅入深的进行询问,有些原理还会给我讲解。

三轮面试一共做了四个题,刚好打在了我的强项上。分别是最短编辑距离,最长回文字符串,变态跳楼梯和树的最长直径。我把这些题全部通关,中间还在百度食堂吃了个饭。

面试耗时3个半小时,到我出公司的时候已经是下午两点半了。

我非常感谢百度,不仅仅是因为它给了我实习的机会,更多的是对我这样一个要强的人来说,一天内连续挂掉两家公司的局面可能真的是无法接受的。

特别是字节的面试让我觉得毫无还手之力,在这种情况下,熊度就显得格外可亲。

进入百度是一个新的开始,我需要做的事情很多:学习一门新的语言,学习服务器上的开发,学习百度的一些内部工具以及...学习使用Mac。

在这些需求里面我直接砍掉了Mac的学习,把重心放在了语言和Linux上,具体的做法是我向Mentor提出把Mac换成ThinkPad。

我Mentor奇怪地跟我说:大部分人都是Windows本想换Mac,很少有你这样Mac想换Win的,我就笑笑说我时间不够用。

其实很巧的是我的Mentor也是ThinkPad,而他的技术非常强,是我们组绝对的实力担当 ,我觉得我技术路线的尽头应该就是我Mentor的样子。白天我看公司内部的各种文档,八点半下班以后我会花两个小时看Linux视频,并且做一些笔记,因为我觉得在工作时间看视频总给人一种在偷玩的感觉。

十点的西北旺还是灯火通明,出了科技园会看到旁边的网易,新浪和腾讯北京。我顺着腾讯的大楼先走到马连洼地铁站,回中关村的地铁一共要倒两趟,然后再从苏州街走回人大。

通勤时间大概在一小时四十分钟,所以到家一般都是十一点多了,我经常会在楼下的KFC买个吮指原味鸡或者鸡米花,静静地坐着吃完再回去洗澡睡觉。

期间我也想过换个住处,但是想了想,现在的房子地段很好,各种生活用品和娱乐都很多,西北旺那片还是有些冷清,还是算了。

还有一个原因是我特别喜欢人大,我在任何一个地方租房都喜欢租在学校旁边,校园的景色和人文气息都让我很舒服。

在百度的周末我一般也用来学习,通常是花一天的时间看Linux,另外一天去公司看文档。我看的书是Linux编程手册,这是我一个大牛同学推荐给我的,他初中就开始玩Linux,在社区比较活跃。

很难想象我这么一个不爱学习的人能静下心来看一本大部头的书看一下午,最深的原因是,我在北京有个漂亮的女同学,我一般都会喊她陪我在她们学校的自习室看一下午,然后去吃个饭看个电影啥的。如果这我都看不进去的话,那我真的不知道该说对不起谁了。

那段时间可能是我在北京最快乐最回忆的时光,每当地铁报站到了芍药居的时候,我都会有些悸动。

我们一起去过中关村的店,后海的店,印象最深的是第一次在万泉河的一家西餐厅。

那天她穿黑色的裙子,化了妆,坐在西餐厅的白色桌布前。我直接想到了十年以后,天是那么蓝,花静静躺在路边,我觉得我一定要好好写代码,成为一个厉害的人因为... ...

这样规律的日子我过了大概有三个月,也是我在百度呆的大半时间,我每周最大的期待就是周末可以看书的时光,因为去别人学校学习知识当然让人快乐啊。

百度楼下的蛋糕店有很好吃的慕斯蛋糕,有时候我会六点就下班,然后大概一个多小时通勤给同学带一个吃,因为这个蛋糕到六点以后基本上就买不到了。

不过很认真的说,我能坚持看Linux很重要的原因也是在字节的面试给我的打击太大了,让我认识到我在Linux这块就是一片空白。并且工作中确实遇到了相关的问题,有一次我做了一个定制化的HDFS上传程序,fork的时候在父进程中没有wait,导致服务器上产生了大量处于僵死状态的进程。

这些进程的执行流已经结束了,可是由于父进程未对它们进行最终处理,导致进程号一直被占用,久而久之可能会影响到服务器上其他进程的pid分配。

还有我们的HDFS资源比较有限,处理的数据量很大,大家的MR任务和Spark任务都在一个HDFS空间上,细碎的文件很多导致INode被占用的很严重,有时候还有磁盘,却没有足够的INode能分配了,导致任务执行失败等等。后来这种类似的问题我都在Linux编程手册中找到了对应的知识点,也让我越来越认识到它的重要性。

差不多两个月之后,我对Python和服务器上的开发已经有了一点点认识了,也开始接了一个新的项目。这个项目在工作计划上的执行人其实是我Mentor,不过他比较忙,我看了下这个活应该可以做,我就接了下来。项目的具体内容在此不方便多提,但是这个项目让我真正的接触到了很多开发中的交流,合作,踩坑,出坑,设计等等等等。

印象最深的是来自Google响应报文里面一个隐式编码的问题,Chunk协议在一些报文中会被使用,用作数据交流格式的标准。

Google的响应报文中使用了这种编码,却没有显式地声明出来,我在对报文进行了DOM树构建和重写之后,改变了报文的字符数,而Google用一个16进制的数字声明了这个长度。这个细节直接导致所有被我篡改的报文均不能在浏览器端被正常解析,表现为无限等待的界面。

这个Bug我足足改了七天,中间有一天我已经无限接近这个答案了,我把一个疑似表示长度的十六进制数进行了还原,想看看它是否指代长度。坑爹的是在服务器上看到的长度是字节数,中间涉及到编码的问题,而这个16进制数指代的是字符数,中间的差值让我一直不敢确定这个是代表长度(其实就算知道了也不可能改对,因为中文字符的字节数在UTF-8下是不同于字符数的)。

在这个开发周期中我熬过最多的夜就是这个时期了,以至于之后的需求性开发我都很轻松的完成,因为我觉得应该没有比这个Bug更加难弄的情况了(中间还有其他的问题,比如URL编码异常,Gzip的隐式刷流,开源库的DOM化缺陷,但是这些慢慢处理就好)。

这期间最让我印象深刻的是在我解决了这个问题之后一天的晚上,隔壁组对接的开发过来问我的经历我是今年的新同事吗,工作多久了。

经理哈哈大笑,说“怎么样,xx牛*吧”。隔壁的开发说确实很不错,我经理又补了一句“xx是我们的实习生”,隔壁的开发惊讶了说“我以为xx都早工作了”。

我全程背对着他们,那一刻我靠在椅子上,他们看不到我嘴角咧起的笑。我很喜欢看程序员生涯记录之类的小说,《疯狂的程序员》里面这样子写道“很多时候,我们开发一个项目,做一个需求,加班,熬夜,干耗,不是为了赶某个工期,或者是任务完成后领导给的拿一笔钱,更重要的是,我们享受这种克服万难,成人所不能的感觉,这种感觉跟钱是不一样的东西”。

正是这样子的瞬间,让我在程序的世界里真正的发现了自己。

故事直到这里,好像都跟微软没什么关系,可能有些朋友很想看如何去微软的过程,但是我个人觉得此处实在是乏善可陈,同时这个事情本身也没那么重要了。

一月下旬我从北京回湖北过年,没几天就遇到了疫情,在老家被辗转着隔离,家里也有亲人感染,可以说整个二月都是在隔离中度过。心里的压力更多来自对亲人健康的关心,到了二月中旬,情况渐渐好转,基本处于康复期了,恰逢学校群里有人发布微软春季实习生招聘,我就发了简历。

二月下旬开始,我在隔离的地方用手机开着热点,抱着公司的ThinkPad开展了新一轮的征程。

实习期间大量的开发任务确实很难抽出时间做这种集中式的复习,这次刷题我的目标很明确,牛客上的剑指Offer和LeetCode148一题不漏全部写完。

其实之前已经实现了大概80%,但是剩下的20%无疑是更加麻烦的,中间穿插着各种DP的训练,还是老规矩,AC才算过。刷累的时候会去整理在爱奇艺和百度的项目,它们的需求点,难点和结果。

这中间还有天在群里吹水,跟人吵起来AVL和红黑树哪个更难,我为了证明红黑树更难还专门花了半天去实现了一个可用的AVL。

写的时候我内心已经是很平静了,记得大学刚学树的时候,我觉得这种代码只能靠背,而且还背不下来。

到现在,这种东西只是去看一遍它的思路,然后在心中大概的复现一次,遇到有问题的点就停下来详细想想。一个数据结构无非ADT和对应的增删改查,然后再想想哪块的代码可以复用,抽离出来。

接下来就是各种边界和小case,头一天晚上我看到两点钟,把全部的思路复现了一遍。第二天起来吃完隔离点送过来的早饭就开始写,直接在记事本里面开始进行实现,然后微调了下,过我自己的case,没啥磕绊就完成了。

后面的微软面试一共做了五个题,最后一面的leader说我对边界条件的分析很到位,是她今天面试的所有人里面最全面最准确的,我当她给了个好消息吧。

同年的四月,我在师弟的帮助下,再次进行了字节跳动的面试,一下午三面过关斩将,也在不久之后收到了字节跳动的意向书。

![](https://tva1.sinaimg.cn/large/007S8ZIlly1gjv21j04xnj315s0kuard.jpg)


坦白来说,这个时期的我心中已经没有了什么波澜,不会特别高兴,也不会再对哪家公司产生特别的展开追逐的那种意愿。一来我已经呆过好几个公司了,那种大公司的憧憬和新鲜感对我而言已经没有那么大的吸引力,同时我也开始认为,一个程序员,他的目光不应该全部放在对哪家公司的追求上。

第一,我们服务于我们具体的业务和相应的技术,具体的业务是比公司这种平台性的东西更加值得讨论的。

第二,追求公司的本质是希望自我的提升,在这种前提下更应该把精力放在如何精进自己的技术水平上,因为公司本身并不能成为一个努力的方向和路线,它只是一个结果。

最后,又是一年的国庆,还是坐在电脑前。闭上眼睛,一路的回忆像浮光搬掠过眼前。

我喜欢看别人故事的原因是,几千个字的篇幅其实写尽了这个人很长的一段经历,浮浮沉沉,故事中的低潮可能在几行文字中就轻描淡写的过去了,读者喜欢去看走出这个低潮再见阳光的感动,其实对这个人来说,这一段恰恰是最难熬最难经历的一段。

我们看着故事中的人,好像自己也会离开这样的谷底。真实的说,这个故事中应该花大篇幅描述的难过我都略去了。

许多面试时候被否定的环节,被问到哑口无言的时刻,丹棱街SOHO微软大楼明灭的红光,冬天寒冷的西北旺东路到万泉河路海淀中街,爱奇艺十点半下班回去还在床上抱着电脑看一两个小时博客的那种追逐,西二旗人头多过Integer.MAX_VALUE的地铁站,一道题一个Bug困整整一个下午的纠结,二月新冠疫情落在家庭的恐慌,隔离时期对于家人的担心,老家甚至没有Wifi的手机热点面试,包括出来实习需要顶住的学业压力。

我把这个故事记录下来,是因为我喜欢《疯狂的程序员》里面的绝影,Boss绝,我想成为他那样的程序员,一个执着于代码,纯粹于代码的程序员。

本科期间我有些想做的事情未能完成,大三的时候武汉一个比中兴还低的本地的国企IT公司我都觉得很不错了,而时至今日我已经去过好几家大型互联网公司实习,拿遍了头部互联网的Offer,亦或者去到微软。

这些东西其实我在那时候并没想过,但是我也更加没想过绝对到不了今天。在晚归的中关村大街上,我经常会想到一首歌《奉献》,这是电影[飞驰人生]的主题曲。长路奉献给远方,岁月奉献给季节,我拿什么奉献给你。我们经常提起奉献,却很少真正理解奉献的样子。

对啊,到这一步,做到纯粹,更多的是热爱带来的奉献,我不是要执着于哪个Boss,或者执着于哪个公司,我是执着于我所热爱的程序,我所热爱的行业。

因为热爱,所以我不计回报,所以我做到比自己更多一点,因为喜欢,所以回忆里更多的是那些奉献与努力的时刻。

再回望去年随便收拾了两件衣服就踏上北京的自己,有些面试官听到我这个经历的时候会先大笑一下,然后说这样子是否有些冒进。也有些面试官因为这个性格将我挂掉,但是我已经过了那种因为别人评价感到疑问的阶段。如果再给我一次机会选择,或者说再给我一百次机会选择,在爱奇艺的那个电话里给的回复也还会是YES。

因为年轻,就是有无限的可能,青春就是不设限的。

阿里的招聘页面上有句话:If not now, when? If not me, who? 官方给出的翻译是“此时此刻,非我莫属”,我觉得差点意思。

时不我待,舍我其谁。

2020年10月3日于西安

写在最后

故事结束了,但是属于这个少年的未来才刚刚开始。

其实这个故事结尾最好的并不是他收到了多少offer,而是他终于找到了这份工作对于他的意义,找到了自已真正热爱的事业。单从这一点来说,他就已经成功了。

而当他拿到那些曾经遥不可及的大厂offer之后再回首,那些在暗淡时的迷茫困顿却不屈不挠用力生长的经历,每一秒都熠熠生辉。

我是敖丙,你知道的越多,你不知道的越多,我们下期见。

查看原文

赞 38 收藏 14 评论 5

敖丙 发布了文章 · 2020-10-14

打工四年总结的数据库知识点

国庆在家无聊,我随手翻了一下家里数据库相关的书籍,这一翻我就看上瘾了,因为大学比较熟悉的一些数据库范式我居然都忘了,怀揣着好奇心我就看了一个小国庆。

看的过程中我也做了一些小笔记,可能没我之前系统文章那么有趣,但是绝对也是干货十足,适合大家去回顾或者面试突击的适合看看,也不多说先放图。

存储引擎

InnoDB

InnoDB 是 MySQL 默认的事务型存储引擎,只要在需要它不支持的特性时,才考虑使用其他存储引擎。

InnoDB 采用 MVCC 来支持高并发,并且实现了四个标准隔离级别(未提交读、提交读、可重复读、可串行化)。其默认级别时可重复读(REPEATABLE READ),在可重复读级别下,通过 MVCC + Next-Key Locking 防止幻读。

主索引时聚簇索引,在索引中保存了数据,从而避免直接读取磁盘,因此对主键查询有很高的性能。

InnoDB 内部做了很多优化,包括从磁盘读取数据时采用的可预测性读,能够自动在内存中创建 hash 索引以加速读操作的自适应哈希索引,以及能够加速插入操作的插入缓冲区等。

InnoDB 支持真正的在线热备份,MySQL 其他的存储引擎不支持在线热备份,要获取一致性视图需要停止对所有表的写入,而在读写混合的场景中,停止写入可能也意味着停止读取。

MyISAM

设计简单,数据以紧密格式存储。对于只读数据,或者表比较小、可以容忍修复操作,则依然可以使用它。

提供了大量的特性,包括压缩表、空间数据索引等。

不支持事务。

不支持行级锁,只能对整张表加锁,读取时会对需要读到的所有表加共享锁,写入时则对表加排它锁。但在表有读取操作的同时,也可以往表中插入新的记录,这被称为并发插入(CONCURRENT INSERT)。

可以手工或者自动执行检查和修复操作,但是和事务恢复以及崩溃恢复不同,可能导致一些数据丢失,而且修复操作是非常慢的。

如果指定了 DELAY_KEY_WRITE 选项,在每次修改执行完成时,不会立即将修改的索引数据写入磁盘,而是会写到内存中的键缓冲区,只有在清理键缓冲区或者关闭表的时候才会将对应的索引块写入磁盘。这种方式可以极大的提升写入性能,但是在数据库或者主机崩溃时会造成索引损坏,需要执行修复操作。

InnoDB 和 MyISAM 的比较

  • 事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。
  • 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。
  • 外键:InnoDB 支持外键。
  • 备份:InnoDB 支持在线热备份。
  • 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
  • 其它特性:MyISAM 支持压缩表和空间数据索引。

索引

B+ Tree 原理

数据结构

B Tree 指的是 Balance Tree,也就是平衡树,平衡树是一颗查找树,并且所有叶子节点位于同一层。

B+ Tree 是 B 树的一种变形,它是基于 B Tree 和叶子节点顺序访问指针进行实现,通常用于数据库和操作系统的文件系统中。

B+ 树有两种类型的节点:内部节点(也称索引节点)和叶子节点,内部节点就是非叶子节点,内部节点不存储数据,只存储索引,数据都存在叶子节点。

内部节点中的 key 都按照从小到大的顺序排列,对于内部节点中的一个 key,左子树中的所有 key 都小于它,右子树中的 key 都大于等于它,叶子节点的记录也是按照从小到大排列的。

每个叶子节点都存有相邻叶子节点的指针。

操作

查找

查找以典型的方式进行,类似于二叉查找树。起始于根节点,自顶向下遍历树,选择其分离值在要查找值的任意一边的子指针。在节点内部典型的使用是二分查找来确定这个位置。

插入

  • Perform a search to determine what bucket the new record should go into.
  • If the bucket is not full(a most b - 1 entries after the insertion,b 是节点中的元素个数,一般是页的整数倍),add tht record.
  • Otherwise,before inserting the new record

    • split the bucket.

      • original node has 「(L+1)/2」items
      • new node has 「(L+1)/2」items
    • Move 「(L+1)/2」-th key to the parent,and insert the new node to the parent.
    • Repeat until a parent is found that need not split.
  • If the root splits,treat it as if it has an empty parent ans split as outline above.

B-trees grow as the root and not at the leaves.

删除

和插入类似,只不过是自下而上的合并操作。

树的常见特性

AVL 树

平衡二叉树,一般是用平衡因子差值决定并通过旋转来实现,左右子树树高差不超过1,那么和红黑树比较它是严格的平衡二叉树,平衡条件非常严格(树高差只有1),只要插入或删除不满足上面的条件就要通过旋转来保持平衡。由于旋转是非常耗费时间的。所以 AVL 树适用于插入/删除次数比较少,但查找多的场景。

红黑树

通过对从根节点到叶子节点路径上各个节点的颜色进行约束,确保没有一条路径会比其他路径长2倍,因而是近似平衡的。所以相对于严格要求平衡的AVL树来说,它的旋转保持平衡次数较少。适合,查找少,插入/删除次数多的场景。(现在部分场景使用跳表来替换红黑树,可搜索“为啥 redis 使用跳表(skiplist)而不是使用 red-black?”)

B/B+ 树

多路查找树,出度高,磁盘IO低,一般用于数据库系统中。

B + 树与红黑树的比较

红黑树等平衡树也可以用来实现索引,但是文件系统及数据库系统普遍采用 B+ Tree 作为索引结构,主要有以下两个原因:

(一)磁盘 IO 次数

B+ 树一个节点可以存储多个元素,相对于红黑树的树高更低,磁盘 IO 次数更少。

(二)磁盘预读特性

为了减少磁盘 I/O 操作,磁盘往往不是严格按需读取,而是每次都会预读。预读过程中,磁盘进行顺序读取,顺序读取不需要进行磁盘寻道。每次会读取页的整数倍。

操作系统一般将内存和磁盘分割成固定大小的块,每一块称为一页,内存与磁盘以页为单位交换数据。数据库系统将索引的一个节点的大小设置为页的大小,使得一次 I/O 就能完全载入一个节点。

B + 树与 B 树的比较

B+ 树的磁盘 IO 更低

B+ 树的内部节点并没有指向关键字具体信息的指针。因此其内部节点相对 B 树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了。

B+ 树的查询效率更加稳定

由于非叶子结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当。

B+ 树元素遍历效率高

B 树在提高了磁盘IO性能的同时并没有解决元素遍历的效率低下的问题。正是为了解决这个问题,B+树应运而生。B+树只要遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而 B 树不支持这样的操作(或者说效率太低)。

MySQL 索引

索引是在存储引擎层实现的,而不是在服务器层实现的,所以不同存储引擎具有不同的索引类型和实现。

B+ Tree 索引

是大多数 MySQL 存储引擎的默认索引类型。

  • 因为不再需要进行全表扫描,只需要对树进行搜索即可,所以查找速度快很多。
  • 因为 B+ Tree 的有序性,所以除了用于查找,还可以用于排序和分组。
  • 可以指定多个列作为索引列,多个索引列共同组成键。
  • 适用于全键值、键值范围和键前缀查找,其中键前缀查找只适用于最左前缀查找。如果不是按照索引列的顺序进行查找,则无法使用索引。

InnoDB 的 B+Tree 索引分为主索引和辅助索引。主索引的叶子节点 data 域记录着完整的数据记录,这种索引方式被称为聚簇索引。因为无法把数据行存放在两个不同的地方,所以一个表只能有一个聚簇索引。

辅助索引的叶子节点的 data 域记录着主键的值,因此在使用辅助索引进行查找时,需要先查找到主键值,然后再到主索引中进行查找,这个过程也被称作回表。

哈希索引

哈希索引能以 O(1) 时间进行查找,但是失去了有序性:

  • 无法用于排序与分组;
  • 只支持精确查找,无法用于部分查找和范围查找。

InnoDB 存储引擎有一个特殊的功能叫“自适应哈希索引”,当某个索引值被使用的非常频繁时,会在 B+Tree 索引之上再创建一个哈希索引,这样就让 B+Tree 索引具有哈希索引的一些优点,比如快速的哈希查找。

全文索引

MyISAM 存储引擎支持全文索引,用于查找文本中的关键词,而不是直接比较是否相等。

查找条件使用 MATCH AGAINST,而不是普通的 WHERE。

全文索引使用倒排索引实现,它记录着关键词到其所在文档的映射。

InnoDB 存储引擎在 MySQL 5.6.4 版本中也开始支持全文索引。

空间数据索引

MyISAM 存储引擎支持空间数据索引(R-Tree),可以用于地理数据存储。空间数据索引会从所有维度来索引数据,可以有效地使用任意维度来进行组合查询。

必须使用 GIS 相关的函数来维护数据。

索引优化

独立的列

在进行查询时,索引列不能是表达式的一部分,也不能是函数的参数,否则无法使用索引。

例如下面的查询不能使用 actor_id 列的索引:

SELECT actor_id FROM sakila.actor WHERE actor_id + 1 = 5;

多列索引

在需要使用多个列作为条件进行查询时,使用多列索引比使用多个单列索引性能更好。例如下面的语句中,最好把 actor_id 和 film_id 设置为多列索引。

SELECT film_id, actor_ id FROM sakila.film_actor
WHERE actor_id = 1 AND film_id = 1;

索引列的顺序

让选择性最强的索引列放在前面。

索引的选择性是指:不重复的索引值和记录总数的比值。最大值为 1,此时每个记录都有唯一的索引与其对应。选择性越高,每个记录的区分度越高,查询效率也越高。

例如下面显示的结果中 customer_id 的选择性比 staff_id 更高,因此最好把 customer_id 列放在多列索引的前面。

SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity,
COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity,
COUNT(*)
FROM payment;
   staff_id_selectivity: 0.0001
customer_id_selectivity: 0.0373
               COUNT(*): 16049

前缀索引

对于 BLOB、TEXT 和 VARCHAR 类型的列,必须使用前缀索引,只索引开始的部分字符。

前缀长度的选取需要根据索引选择性来确定。

覆盖索引

索引包含所有需要查询的字段的值。

具有以下优点:

  • 索引通常远小于数据行的大小,只读取索引能大大减少数据访问量。
  • 一些存储引擎(例如 MyISAM)在内存中只缓存索引,而数据依赖于操作系统来缓存。因此,只访问索引可以不使用系统调用(通常比较费时)。
  • 对于 InnoDB 引擎,若辅助索引能够覆盖查询,则无需访问主索引。

索引的优点

  • 大大减少了服务器需要扫描的数据行数。
  • 帮助服务器避免进行排序和分组,以及避免创建临时表(B+Tree 索引是有序的,可以用于 ORDER BY 和 GROUP BY 操作。临时表主要是在排序和分组过程中创建,不需要排序和分组,也就不需要创建临时表)。
  • 将随机 I/O 变为顺序 I/O(B+Tree 索引是有序的,会将相邻的数据都存储在一起)。

索引的使用条件

  • 对于非常小的表、大部分情况下简单的全表扫描比建立索引更高效;
  • 对于中到大型的表,索引就非常有效;
  • 但是对于特大型的表,建立和维护索引的代价将会随之增长。这种情况下,需要用到一种技术可以直接区分出需要查询的一组数据,而不是一条记录一条记录地匹配,例如可以使用分区技术。
为什么对于非常小的表,大部分情况下简单的全表扫描比建立索引更高效?

如果一个表比较小,那么显然直接遍历表比走索引要快(因为需要回表)。

注:首先,要注意这个答案隐含的条件是查询的数据不是索引的构成部分,否也不需要回表操作。其次,查询条件也不是主键,否则可以直接从聚簇索引中拿到数据。

查询性能优化

使用 explain 分析 select 查询语句

explain 用来分析 SELECT 查询语句,开发人员可以通过分析 Explain 结果来优化查询语句。

select_type

常用的有 SIMPLE 简单查询,UNION 联合查询,SUBQUERY 子查询等。

table

要查询的表

possible_keys

The possible indexes to choose

可选择的索引

key

The index actually chosen

实际使用的索引

rows

Estimate of rows to be examined

扫描的行数

type

索引查询类型,经常用到的索引查询类型:

**const:使用主键或者唯一索引进行查询的时候只有一行匹配
ref:使用非唯一索引
range:使用主键、单个字段的辅助索引、多个字段的辅助索引的最后一个字段进行范围查询
index:和all的区别是扫描的是索引树
all:扫描全表:**

system

触发条件:表只有一行,这是一个 const type 的特殊情况

const

触发条件:在使用主键或者唯一索引进行查询的时候只有一行匹配。

SELECT * FROM tbl_name WHERE primary_key=1;

SELECT * FROM tbl_name
  WHERE primary_key_part1=1 AND primary_key_part2=2;

eq_ref

触发条件:在进行联接查询的,使用主键或者唯一索引并且只匹配到一行记录的时候

SELECT * FROM ref_table,other_table
  WHERE ref_table.key_column=other_table.column;

SELECT * FROM ref_table,other_table
  WHERE ref_table.key_column_part1=other_table.column
  AND ref_table.key_column_part2=1;

ref

触发条件:使用非唯一索引

SELECT * FROM ref_table WHERE key_column=expr;

SELECT * FROM ref_table,other_table
  WHERE ref_table.key_column=other_table.column;

SELECT * FROM ref_table,other_table
  WHERE ref_table.key_column_part1=other_table.column
  AND ref_table.key_column_part2=1;

range

触发条件:只有在使用主键、单个字段的辅助索引、多个字段的辅助索引的最后一个字段进行范围查询才是 range

SELECT * FROM tbl_name
  WHERE key_column = 10;

SELECT * FROM tbl_name
  WHERE key_column BETWEEN 10 and 20;

SELECT * FROM tbl_name
  WHERE key_column IN (10,20,30);

SELECT * FROM tbl_name
  WHERE key_part1 = 10 AND key_part2 IN (10,20,30);

index

The index join type is the same as ALL, except that the index tree is scanned. This occurs two ways:

触发条件:

只扫描索引树

1)查询的字段是索引的一部分,覆盖索引。
2)使用主键进行排序

all

触发条件:全表扫描,不走索引

优化数据访问

减少请求的数据量

  • 只返回必要的列:最好不要使用 SELECT * 语句。
  • 只返回必要的行:使用 LIMIT 语句来限制返回的数据。
  • 缓存重复查询的数据:使用缓存可以避免在数据库中进行查询,特别在要查询的数据经常被重复查询时,缓存带来的查询性能提升将会是非常明显的。

减少服务器端扫描的行数

最有效的方式是使用索引来覆盖查询。

重构查询方式

切分大查询

一个大查询如果一次性执行的话,可能一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。

DELETE FROM messages WHERE create < DATE_SUB(NOW(), INTERVAL 3 MONTH);
rows_affected = 0
do {
    rows_affected = do_query(
    "DELETE FROM messages WHERE create  < DATE_SUB(NOW(), INTERVAL 3 MONTH) LIMIT 10000")
} while rows_affected > 0

分解大连接查询

将一个大连接查询分解成对每一个表进行一次单表查询,然后在应用程序中进行关联,这样做的好处有:

  • 让缓存更高效。对于连接查询,如果其中一个表发生变化,那么整个查询缓存就无法使用。而分解后的多个查询,即使其中一个表发生变化,对其它表的查询缓存依然可以使用。
  • 分解成多个单表查询,这些单表查询的缓存结果更可能被其它查询使用到,从而减少冗余记录的查询。
  • 减少锁竞争;
  • 在应用层进行连接,可以更容易对数据库进行拆分,从而更容易做到高性能和可伸缩。
  • 查询本身效率也可能会有所提升。例如下面的例子中,使用 IN() 代替连接查询,可以让 MySQL 按照 ID 顺序进行查询,这可能比随机的连接要更高效。
SELECT * FROM tag
JOIN tag_post ON tag_post.tag_id=tag.id
JOIN post ON tag_post.post_id=post.id
WHERE tag.tag='mysql';
SELECT * FROM tag WHERE tag='mysql';
SELECT * FROM tag_post WHERE tag_id=1234;
SELECT * FROM post WHERE post.id IN (123,456,567,9098,8904);

事务

事务是指满足 ACID 特性的一组操作,可以通过 Commit 提交一个事务,也可以使用 Rollback 进行回滚。

ACID

事务最基本的莫过于 ACID 四个特性了,这四个特性分别是:

  • Atomicity:原子性
  • Consistency:一致性
  • Isolation:隔离性
  • Durability:持久性

原子性

事务被视为不可分割的最小单元,事务的所有操作要么全部成功,要么全部失败回滚。

一致性

数据库在事务执行前后都保持一致性状态,在一致性状态下,所有事务对一个数据的读取结果都是相同的。

隔离性

一个事务所做的修改在最终提交以前,对其他事务是不可见的。

持久性

一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢。

ACID 之间的关系

事务的 ACID 特性概念很简单,但不好理解,主要是因为这几个特性不是一种平级关系:

  • 只有满足一致性,事务的结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时只要能满足原子性,就一定能满足一致性。在并发的情况下,多个事务并行执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对数据库崩溃的情况。

隔离级别

未提交读(READ UNCOMMITTED)

事务中的修改,即使没有提交,对其他事务也是可见的。

提交读(READ COMMITTED)

一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其他事务是不可见的。

可重复读(REPEATABLE READ)

保证在同一个事务中多次读取同样数据的结果是一样的。

可串行化(SERIALIZABLE)

强制事务串行执行。

需要加锁实现,而其它隔离级别通常不需要。

隔离级别脏读不可重复读幻影读
未提交读
提交读×
可重复读××
可串行化×××

锁是数据库系统区别于文件系统的一个关键特性。锁机制用于管理对共享资源的并发访问。

锁类型

共享锁(S Lock)

允许事务读一行数据

排他锁(X Lock)

允许事务删除或者更新一行数据

意向共享锁(IS Lock)

事务想要获得一张表中某几行的共享锁

意向排他锁

事务想要获得一张表中某几行的排他锁

MVCC

多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。

基础概念

版本号

  • 系统版本号:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
  • 事务版本号:事务开始时的系统版本号。

隐藏的列

MVCC 在每行记录后面都保存着两个隐藏的列,用来存储两个版本号:

  • 创建版本号:指示创建一个数据行的快照时的系统版本号;
  • 删除版本号:如果该快照的删除版本号大于当前事务版本号表示该快照有效,否则表示该快照已经被删除了。

Undo 日志

MVCC 使用到的快照存储在 Undo 日志中,该日志通过回滚指针把一个数据行(Record)的所有快照连接起来。

实现过程

以下实现过程针对可重复读隔离级别。

当开始一个事务时,该事务的版本号肯定大于当前所有数据行快照的创建版本号,理解这一点很关键。数据行快照的创建版本号是创建数据行快照时的系统版本号,系统版本号随着创建事务而递增,因此新创建一个事务时,这个事务的系统版本号比之前的系统版本号都大,也就是比所有数据行快照的创建版本号都大。

SELECT

多个事务必须读取到同一个数据行的快照,并且这个快照是距离现在最近的一个有效快照。但是也有例外,如果有一个事务正在修改该数据行,那么它可以读取事务本身所做的修改,而不用和其它事务的读取结果一致。

把没有对一个数据行做修改的事务称为 T,T 所要读取的数据行快照的创建版本号必须小于等于 T 的版本号,因为如果大于 T 的版本号,那么表示该数据行快照是其它事务的最新修改,因此不能去读取它。除此之外,T 所要读取的数据行快照的删除版本号必须是未定义或者大于 T 的版本号,因为如果小于等于 T 的版本号,那么表示该数据行快照是已经被删除的,不应该去读取它。

INSERT

将当前系统版本号作为数据行快照的创建版本号。

DELETE

将当前系统版本号作为数据行快照的删除版本号。

UPDATE

将当前系统版本号作为更新前的数据行快照的删除版本号,并将当前系统版本号作为更新后的数据行快照的创建版本号。可以理解为先执行 DELETE 后执行 INSERT。

快照读与当前读

在可重复读级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据,不是数据库当前的数据!这在一些对于数据的时效特别敏感的业务中,就很可能出问题。

对于这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库当前版本数据的方式,叫当前读 (current read)。很显然,在MVCC中:

快照读

MVCC 的 SELECT 操作是快照中的数据,不需要进行加锁操作。

select * from table ….;

当前读

MVCC 其它会对数据库进行修改的操作(INSERT、UPDATE、DELETE)需要进行加锁操作,从而读取最新的数据。可以看到 MVCC 并不是完全不用加锁,而只是避免了 SELECT 的加锁操作。

INSERT;
UPDATE;
DELETE;

在进行 SELECT 操作时,可以强制指定进行加锁操作。以下第一个语句需要加 S 锁,第二个需要加 X 锁。

- select * from table where ? lock in share mode;
- select * from table where ? for update;

事务的隔离级别实际上都是定义的当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”的隔离性,就需要通过加锁来实现了。

锁算法

Record Lock

锁定一个记录上的索引,而不是记录本身。

如果表没有设置索引,InnoDB 会自动在主键上创建隐藏的聚簇索引,因此 Record Locks 依然可以使用。

Gap Lock

锁定索引之间的间隙,但是不包含索引本身。例如当一个事务执行以下语句,其它事务就不能在 t.c 中插入 15。

SELECT c FROM t WHERE c BETWEEN 10 and 20 FOR UPDATE;

Next-Key Lock

它是 Record Locks 和 Gap Locks 的结合,不仅锁定一个记录上的索引,也锁定索引之间的间隙。例如一个索引包含以下值:10, 11, 13, and 20,那么就需要锁定以下区间:

(-∞, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +∞)
在 InnoDB 存储引擎中,SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题通过 Record Lock 解决,INSERT 的不可重复读问题是通过 Next-Key Lock(Record Lock + Gap Lock)解决的。

锁问题

脏读

脏读指的是不同事务下,当前事务可以读取到另外事务未提交的数据。

例如:

T1 修改一个数据,T2 随后读取这个数据。如果 T1 撤销了这次修改,那么 T2 读取的数据是脏数据。

不可重复读

不可重复读指的是同一事务内多次读取同一数据集合,读取到的数据是不一样的情况。

例如:

T2 读取一个数据,T1 对该数据做了修改。如果 T2 再次读取这个数据,此时读取的结果和第一次读取的结果不同。

在 InnoDB 存储引擎中,SELECT 操作的不可重复读问题通过 MVCC 得到了解决,而 UPDATE、DELETE 的不可重复读问题是通过 Record Lock 解决的,INSERT 的不可重复读问题是通过 Next-Key Lock(Record Lock + Gap Lock)解决的。

Phantom Proble(幻影读)

The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.

Phantom Proble 是指在同一事务下,连续执行两次同样的 sql 语句可能返回不同的结果,第二次的 sql 语句可能会返回之前不存在的行。

幻影读是一种特殊的不可重复读问题。

丢失更新

一个事务的更新操作会被另一个事务的更新操作所覆盖。

例如:

T1 和 T2 两个事务都对一个数据进行修改,T1 先修改,T2 随后修改,T2 的修改覆盖了 T1 的修改。

这类型问题可以通过给 SELECT 操作加上排他锁来解决,不过这可能会引入性能问题,具体使用要视业务场景而定。

分库分表数据切分

水平切分

水平切分又称为 Sharding,它是将同一个表中的记录拆分到多个结构相同的表中。

当一个表的数据不断增多时,Sharding 是必然的选择,它可以将数据分布到集群的不同节点上,从而缓存单个数据库的压力。

垂直切分

垂直切分是将一张表按列分成多个表,通常是按照列的关系密集程度进行切分,也可以利用垂直气氛将经常被使用的列喝不经常被使用的列切分到不同的表中。

在数据库的层面使用垂直切分将按数据库中表的密集程度部署到不通的库中,例如将原来电商数据部署库垂直切分称商品数据库、用户数据库等。

Sharding 策略

  • 哈希取模:hash(key)%N
  • 范围:可以是 ID 范围也可以是时间范围
  • 映射表:使用单独的一个数据库来存储映射关系

Sharding 存在的问题

事务问题

使用分布式事务来解决,比如 XA 接口

连接

可以将原来的连接分解成多个单表查询,然后在用户程序中进行连接。

唯一性

  • 使用全局唯一 ID (GUID)
  • 为每个分片指定一个 ID 范围
  • 分布式 ID 生成器(如 Twitter 的 Snowflake 算法)

复制

主从复制

主要涉及三个线程:binlog 线程、I/O 线程和 SQL 线程。

  • binlog 线程 :负责将主服务器上的数据更改写入二进制日志(Binary log)中。
  • I/O 线程 :负责从主服务器上读取- 二进制日志,并写入从服务器的中继日志(Relay log)。
  • SQL 线程 :负责读取中继日志,解析出主服务器已经执行的数据更改并在从服务器中重放(Replay)。

读写分离

主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。

读写分离能提高性能的原因在于:

  • 主从服务器负责各自的读和写,极大程度缓解了锁的争用;
  • 从服务器可以使用 MyISAM,提升查询性能以及节约系统开销;
  • 增加冗余,提高可用性。

读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。

JSON

在实际业务中经常会使用到 JSON 数据类型,在查询过程中主要有两种使用需求:

  1. 在 where 条件中有通过 json 中的某个字段去过滤返回结果的需求
  2. 查询 json 字段中的部分字段作为返回结果(减少内存占用)

JSON_CONTAINS

JSON_CONTAINS(target, candidate[, path])

如果在 json 字段 target 指定的位置 path,找到了目标值 condidate,返回 1,否则返回 0

如果只是检查在指定的路径是否存在数据,使用JSON_CONTAINS_PATH()

mysql> SET @j = '{"a": 1, "b": 2, "c": {"d": 4}}';
mysql> SET @j2 = '1';
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.a');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.a') |
+-------------------------------+
|                             1 |
+-------------------------------+
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.b');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.b') |
+-------------------------------+
|                             0 |
+-------------------------------+

mysql> SET @j2 = '{"d": 4}';
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.a');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.a') |
+-------------------------------+
|                             0 |
+-------------------------------+
mysql> SELECT JSON_CONTAINS(@j, @j2, '$.c');
+-------------------------------+
| JSON_CONTAINS(@j, @j2, '$.c') |
+-------------------------------+
|                             1 |
+-------------------------------+

JSON_CONTAINS_PATH

JSON_CONTAINS_PATH(json_doc, one_or_all, path[, path] ...)

如果在指定的路径存在数据返回 1,否则返回 0

mysql> SET @j = '{"a": 1, "b": 2, "c": {"d": 4}}';
mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.a', '$.e');
+---------------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.a', '$.e') |
+---------------------------------------------+
|                                           1 |
+---------------------------------------------+
mysql> SELECT JSON_CONTAINS_PATH(@j, 'all', '$.a', '$.e');
+---------------------------------------------+
| JSON_CONTAINS_PATH(@j, 'all', '$.a', '$.e') |
+---------------------------------------------+
|                                           0 |
+---------------------------------------------+
mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.c.d');
+----------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.c.d') |
+----------------------------------------+
|                                      1 |
+----------------------------------------+
mysql> SELECT JSON_CONTAINS_PATH(@j, 'one', '$.a.d');
+----------------------------------------+
| JSON_CONTAINS_PATH(@j, 'one', '$.a.d') |
+----------------------------------------+
|                                      0 |
+----------------------------------------+

实际使用:

        $conds = new Criteria();
        $conds->andWhere('dept_code', 'in', $deptCodes);
        if (!empty($aoiAreaId)) {
            $aoiAreaIdCond = new Criteria();
            $aoiAreaIdCond->orWhere("JSON_CONTAINS_PATH(new_aoi_area_ids,'one', '$.\"$aoiAreaId\"')", '=', 1);
            $aoiAreaIdCond->orWhere("JSON_CONTAINS_PATH(old_aoi_area_ids,'one', '$.\"$aoiAreaId\"')", '=', 1);
            $conds->andWhere($aoiAreaIdCond);
        }

column->path、column->>path

获取指定路径的值

-> vs ->>

Whereas the -> operator simply extracts a value, the ->> operator in addition unquotes the extracted result.

mysql> SELECT * FROM jemp WHERE g > 2;
+-------------------------------+------+
| c                             | g    |
+-------------------------------+------+
| {"id": "3", "name": "Barney"} |    3 |
| {"id": "4", "name": "Betty"}  |    4 |
+-------------------------------+------+
2 rows in set (0.01 sec)

mysql> SELECT c->'$.name' AS name
    ->     FROM jemp WHERE g > 2;
+----------+
| name     |
+----------+
| "Barney" |
| "Betty"  |
+----------+
2 rows in set (0.00 sec)

mysql> SELECT JSON_UNQUOTE(c->'$.name') AS name
    ->     FROM jemp WHERE g > 2;
+--------+
| name   |
+--------+
| Barney |
| Betty  |
+--------+
2 rows in set (0.00 sec)

mysql> SELECT c->>'$.name' AS name
    ->     FROM jemp WHERE g > 2;
+--------+
| name   |
+--------+
| Barney |
| Betty  |
+--------+
2 rows in set (0.00 sec)

实际使用:

$retTask = AoiAreaTaskOrm::findRows(['status', 'extra_info->>"$.new_aoi_area_infos" as new_aoi_area_infos', 'extra_info->>"$.old_aoi_area_infos" as old_aoi_area_infos'], $cond);

关系数据库设计理论

函数依赖

记 A->B 表示 A 函数决定 B,也可以说 B 函数依赖于 A。

如果 {A1,A2,... ,An} 是关系的一个或多个属性的集合,该集合函数决定了关系的其它所有属性并且是最小的,那么该集合就称为键码。

对于 A->B,如果能找到 A 的真子集 A',使得 A'-> B,那么 A->B 就是部分函数依赖,否则就是完全函数依赖。

对于 A->B,B->C,则 A->C 是一个传递函数依赖

异常

以下的学生课程关系的函数依赖为 {Sno, Cname} -> {Sname, Sdept, Mname, Grade},键码为 {Sno, Cname}。也就是说,确定学生和课程之后,就能确定其它信息。

SnoSnameSdeptMnameCnameGrade
1学生-1学院-1院长-1课程-190
2学生-2学院-2院长-2课程-280
2学生-2学院-2院长-2课程-1100
3学生-3学院-2院长-2课程-295

不符合范式的关系,会产生很多异常,主要有以下四种异常:

  • 冗余数据:例如 学生-2 出现了两次。
  • 修改异常:修改了一个记录中的信息,但是另一个记录中相同的信息却没有被修改。
  • 删除异常:删除一个信息,那么也会丢失其它信息。例如删除了 课程-1 需要删除第一行和第三行,那么 学生-1 的信息就会丢失。
  • 插入异常:例如想要插入一个学生的信息,如果这个学生还没选课,那么就无法插入。

范式

范式理论是为了解决以上提到四种异常。

高级别范式的依赖于低级别的范式,1NF 是最低级别的范式。

第一范式 (1NF)

属性不可分。

第二范式 (2NF)

每个非主属性完全函数依赖于键码。

可以通过分解来满足。

分解前

SnoSnameSdeptMnameCnameGrade
1学生-1学院-1院长-1课程-190
2学生-2学院-2院长-2课程-280
2学生-2学院-2院长-2课程-1100
3学生-3学院-2院长-2课程-295

以上学生课程关系中,{Sno, Cname} 为键码,有如下函数依赖:

  • Sno -> Sname, Sdept
  • Sdept -> Mname
  • Sno, Cname-> Grade

Grade 完全函数依赖于键码,它没有任何冗余数据,每个学生的每门课都有特定的成绩。

Sname, Sdept 和 Mname 都部分依赖于键码,当一个学生选修了多门课时,这些数据就会出现多次,造成大量冗余数据。

分解后

关系-1

SnoSnameSdeptMname
1学生-1学院-1院长-1
2学生-2学院-2院长-2
3学生-3学院-2院长-2

有以下函数依赖:

  • Sno -> Sname, Sdept
  • Sdept -> Mname

关系-2

SnoCnameGrade
1课程-190
2课程-280
2课程-1100
3课程-295

有以下函数依赖:

  • Sno, Cname -> Grade

第三范式 (3NF)

非主属性不传递函数依赖于键码。

上面的 关系-1 中存在以下传递函数依赖:

  • Sno -> Sdept -> Mname

可以进行以下分解:

关系-11

SnoSnameSdept
1学生-1学院-1
2学生-2学院-2
3学生-3学院-2

关系-12

SdeptMname
学院-1院长-1
学院-2院长-2

ER 图

Entity-Relationship,有三个组成部分:实体、属性、联系。

用来进行关系型数据库系统的概念设计。

实体的三种联系

包含一对一,一对多,多对多三种。

  • 如果 A 到 B 是一对多关系,那么画个带箭头的线段指向 B;
  • 如果是一对一,画两个带箭头的线段;
  • 如果是多对多,画两个不带箭头的线段。

下图的 Course 和 Student 是一对多的关系。

表示出现多次的关系

一个实体在联系出现几次,就要用几条线连接。

下图表示一个课程的先修关系,先修关系出现两个 Course 实体,第一个是先修课程,后一个是后修课程,因此需要用两条线来表示这种关系。

联系的多向性

虽然老师可以开设多门课,并且可以教授多名学生,但是对于特定的学生和课程,只有一个老师教授,这就构成了一个三元联系。

表示子类

用一个三角形和两条线来连接类和子类,与子类有关的属性和联系都连到子类上,而与父类和子类都有关的连到父类上。

参考资料

总结

这都是些基础知识,我没想到再次回顾大半我都已忘却了,也庆幸有这样的假期能够重新拾起来。

说实话做自媒体后我充电的时间少了很多,也少了很多时间研究技术栈深度,国庆假期我也思考反思了很久,后面准备继续压缩自己业余时间,比如看手机看B站的时间压缩一下,还是得按时充电,目前作息还算规律早睡早起都做到了,我们一起加油哟。

我是敖丙,你知道的越多,你不知道的越多,感谢各位人才的:点赞收藏评论,我们下期见!

查看原文

赞 60 收藏 43 评论 3

敖丙 发布了文章 · 2020-10-12

Dubbo常见面试题

前言

Dubbo 整体介绍的差不多了,今天就开始面试环节了,我会列举一些常见的 Dubbo 面试题,只会抓着重的,一些太简单的我就不提了。

不仅仅给你面试题的答案,也会剖析面试官问这个问题的原因,也就是他的内心活动。

想从你这里问出什么?想要什么答案?想挖什么坑给你跳?

开始表演

知道什么是 RPC 么?

一般面试官会以这样的问题来切入、热场,毕面试也是循序渐进的过程,所以你也不用太心急一开始就芭芭拉说一堆,要抓住关键点简单阐述先。

而且面试官能从这个问题鉴定出你平日的工作内容会不会连 RPC 都没接触过,会不会就只是一条龙的 Spring MVC ?

确实有很多同学没接触过 RPC ,也很正常比如一些外包或者一些小项目都接触不到的,不过平日接触不到和你不知道这个东西是两个概念。

能从侧面反映出这个人工作之余应该不怎么学习,连 RPC 都不知道,所以怎么都说不过去,基本上要凉凉,对你的初始印象就差了,除非你能从后面有亮眼的表现。

答:RPC 就是 Remote Procedure Call,远程过程调用,它相对应的是本地过程调用。

那为什么要有 RPC,HTTP 不好么?

这时候面试官就开始追问了。

这个问题其实很有意思,有些面试官可能自己不太清楚,然后以为自己很清楚,所以问出这个问题,还有一种是真的清楚,问这个问题是为了让你跳坑里。

因为 RPC 和 HTTP 就不是一个层级的东西,所以严格意义上这两个没有可比性,也不应该来作比较,而题目问的就是把这两个作为比较了。

HTTP 只是传输协议,协议只是规范了一定的交流格式,而且 RPC 是早于 HTTP 的,所以真要问也是问有 RPC 为什么还要 HTTP。

RPC 对比的是本地过程调用,是用来作为分布式系统之间的通信,它可以用 HTTP 来传输,也可以基于 TCP 自定义协议传输。

所以你要先提出这两个不是一个层级的东西,没有可比性,然后再表现一下,可以说 HTTP 协议比较冗余,所以 RPC 大多都是基于 TCP 自定义协议,定制化的才是最适合自己的。

当然也有基于 HTTP 协议的 RPC 框架,毕竟 HTTP 是公开的协议,比较通用,像 HTTP2 已经做了相应的压缩了,而且系统之间的调用都在内网,所以说影响也不会很大。

这波回答下来,面试官会觉得你有点东西,开始对你有点兴趣了,要开始深入你了。

说说你对 Dubbo 的了解?

面试官会先问个大点的问题,然后从你的回答中找到一些突破口来深入问,所以这个问题其实挺开放性的,你可以从历史的发展来答,也可以从整体架构来答。

如果从历史发展的角度来答,说明你平日里也是挺关注一些开源软件的,侧面也能体现你的对开源的拥抱。

如果从总体架构答,毋庸置疑肯定也是可以的,建议先浅显的说,等着追问。

历史发展,这个其实丙之前文章已经提到了:

Dubbo 是阿里巴巴开源的一个基于 Java 的 RPC 框架,中间沉寂了一段时间,但在 2017 年阿里巴巴又重启了对 Dubbo 维护。

并且在 2018 年和 当当的 Dubbox 进行了合并,进入 Apache 孵化器,在 2019 年毕业正式成为 Apache 顶级项目。

目前 Dubbo 社区主力维护的是 2.6.x 和 2.7.x 两大版本,2.6.x 版本主要是 bug 修复和少量功能增强为准,是稳定版本。

2.7.5 版本的发布被 Dubbo 认为是里程碑式的版本发布,支持 gRPC,并且性能提升了 30%(这里不了解gRPC 和为什么性能提升的话就别说了,别给自己挖坑)。

最新的 3.0 版本往云原生方向上探索着。

注意了,如果对历史各个版本不太熟,也不知道最新的版本要干啥就别往这方向答了,运气好点就是面试官自己也不太了解,他可能不会问,运气背点就追问了。

总体架构,上面也提到了先浅显的说,等追问,因为面试官如果懂,他肯定会问深入,如果不懂你芭芭拉一堆他无感的。

你就简单的提一下现在这几个角色。

节点角色说明
Consumer需要调用远程服务的服务消费方
Registry注册中心
Provider服务提供方
Container服务运行的容器
Monitor监控中心

比如, Dubbo 总体分了以上这么几个角色,分别的作用是xxxx。

这里停顿下看下面试官的反应,如果没搭话,就继续说大致的流程。

首先服务提供者 Provider 启动然后向注册中心注册自己所能提供的服务。

服务消费者 Consumer 启动向注册中心订阅自己所需的服务。然后注册中心将提供者元信息通知给 Consumer, 之后 Consumer 因为已经从注册中心获取提供者的地址,因此可以通过负载均衡选择一个 Provider 直接调用 。

之后服务提供方元数据变更的话注册中心会把变更推送给服务消费者。

服务提供者和消费者都会在内存中记录着调用的次数和时间,然后定时的发送统计数据到监控中心。

到这基本上就差不多了,如果之前看过丙的 Dubbo 系列文章的话,那就算看过源码了,肯定对一系列过程很清晰了,所以在适当的时机可以说自己看过 Dubbo 源码。

众所周知,看过源码肯定是加分项,所以这点是要提的。

面试官一听,好家伙看过源码是吧,来说说。

接下来就开始连击了。

看过源码,那说下服务暴露的流程?

服务的暴露起始于 Spring IOC 容器刷新完毕之后,会根据配置参数组装成 URL, 然后根据 URL 的参数来进行本地或者远程调用。

会通过 proxyFactory.getInvoker,利用 javassist 来进行动态代理,封装真的实现类,然后再通过 URL 参数选择对应的协议来进行 protocol.export,默认是 Dubbo 协议。

在第一次暴露的时候会调用 createServer 来创建 Server,默认是 NettyServer。

然后将 export 得到的 exporter 存入一个 Map 中,供之后的远程调用查找,然后会向注册中心注册提供者的信息。

基本上就是这么个流程,说了这些差不多了,太细的谁都记住不。

看过源码,那说下服务引入的流程?

服务的引入时机有两种,第一种是饿汉式,第二种是懒汉式。

饿汉式就是加载完毕就会引入,懒汉式是只有当这个服务被注入到其他类中时启动引入流程,默认是懒汉式。

会先根据配置参数组装成 URL ,一般而言我们都会配置的注册中心,所以会构建 RegistryDirectory
向注册中心注册消费者的信息,并且订阅提供者、配置、路由等节点。

得知提供者的信息之后会进入 Dubbo 协议的引入,会创建 Invoker ,期间会包含 NettyClient,来进行远程通信,最后通过 Cluster 来包装 Invoker,默认是 FailoverCluster,最终返回代理类。

说这么多差不多了,关键的点都提到了。

切忌不要太过细,不要把你知道的都说了,这样会抓不住重点,比如上面的流程你要插入,引入的三种方式:本地引入、直连远程引入、通过注册中心引入。

然后再分别说本地引入怎样的,芭芭拉的就会很乱,所以面试的时候是需要删减的,要直击重点。

其实真实说的应该比我上面说的还要精简点才行,我是怕大家不太清楚说的稍微详细了一些。

看过源码,那说下服务调用的流程?

调用某个接口的方法会调用之前生成的代理类,然后会从 cluster 中经过路由的过滤、负载均衡机制选择一个 invoker 发起远程调用,此时会记录此请求和请求的 ID 等待服务端的响应。

服务端接受请求之后会通过参数找到之前暴露存储的 map,得到相应的 exporter ,然后最终调用真正的实现类,再组装好结果返回,这个响应会带上之前请求的 ID。

消费者收到这个响应之后会通过 ID 去找之前记录的请求,然后找到请求之后将响应塞到对应的 Future 中,唤醒等待的线程,最后消费者得到响应,一个流程完毕。

关键的就是 cluster、路由、负载均衡,然后 Dubbo 默认是异步的,所以请求和响应是如何对应上的。

之后可能还会追问 Dubbo 异步转同步如何实现的之类的,在丙之前文章里面都说了,忘记的同学可以回去看看。

知道什么是 SPI 嘛?

这又是一个方向了,从上面的回答中,不论是从 Dubbo 协议,还是 cluster ,什么 export 方法等等无处不是 SPI 的影子,所以如果是问 Dubbo 方面的问题,问 SPI 是毋庸置疑的,因为源码里 SPI 无处不在,而且 SPI 也是 Dubbo 可扩展性的基石。

所以这个题目没什么套路,直接答就行。

SPI 是 Service Provider Interface,主要用于框架中,框架定义好接口,不同的使用者有不同的需求,因此需要有不同的实现,而 SPI 就通过定义一个特定的位置,Java SPI 约定在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,然后文件里面记录的是此 jar 包提供的具体实现类的全限定名

所以就可以通过接口找到对应的文件,获取具体的实现类然后加载即可,做到了灵活的替换具体的实现类。

为什么 Dubbo 不用 JDK 的 SPI,而是要自己实现?

问这个问题就是看你有没有深入的了解,或者自己思考过,不是死板的看源码,或者看一些知识点。

很多点是要思考的,不是书上说什么就是什么,你要知道这样做的理由,有什么好处和坏处,这很容易看出一个人是死记硬背还是有自己的思考。

答:因为 Java SPI 在查找扩展实现类的时候遍历 SPI 的配置文件并且将实现类全部实例化,假设一个实现类初始化过程比较消耗资源且耗时,但是你的代码里面又用不上它,这就产生了资源的浪费。

因此 Dubbo 就自己实现了一个 SPI,给每个实现类配了个名字,通过名字去文件里面找到对应的实现类全限定名然后加载实例化,按需加载。

这答出来就加分了,面试官心里在拍手了,不错不错有点东西。

Dubbo 为什么默认用 Javassist

上面你回答 Dubbo 用 Javassist 动态代理,所以很可能会问你为什么要用这个代理,可能还会引申出 JDK 的动态代理、ASM、CGLIB。

所以这也是个注意点,如果你不太清楚的话上面的回答就不要扯到动态代理了,如果清楚的话那肯定得提,来诱导面试官来问你动态代理方面的问题,这很关键。

面试官是需要诱导的,毕竟他也想知道你优秀的方面到底有多优秀,你也取长补短,双赢双赢。

来回答下为什么用 Javassist,很简单,就是快,且字节码生成方便

ASM 比 Javassist 更快,但是没有快一个数量级,而Javassist 只需用字符串拼接就可以生成字节码,而 ASM 需要手工生成,成本较高,比较麻烦。

如果让你设计一个 RPC 框架,如何设计?

面试官都很喜欢问这类问题,来考验候选人的设计能力,和平日有无全方面的了解过一个框架。

如果你平时没有思考,没有往这方面想过答出来的东西就会没有条理性,会显得杂乱无章,不过你也不用慌张,不用想的很全面,答的很细致,没有必要,面试官要的是那些关键的重点。

你可以从底层向上开始说起

首先需要实现高性能的网络传输,可以采用 Netty 来实现,不用自己重复造轮子,然后需要自定义协议,毕竟远程交互都需要遵循一定的协议,然后还需要定义好序列化协议,网络的传输毕竟都是二进制流传输的。

然后可以搞一套描述服务的语言,即 IDL(Interface description language),让所有的服务都用 IDL 定义,再由框架转换为特定编程语言的接口,这样就能跨语言了。

此时最近基本的功能已经有了,但是只是最基础的,工业级的话首先得易用,所以框架需要把上述的细节对使用者进行屏蔽,让他们感觉不到本地调用和远程调用的区别,所以需要代理实现。

然后还需要实现集群功能,因此的要服务发现、注册等功能,所以需要注册中心,当然细节还是需要屏蔽的。

最后还需要一个完善的监控机制,埋点上报调用情况等等,便于运维。

这样一个 RPC 框架的雏形就差不多了。

最后

Dubbo 系列就到此结束了,其实还是有很多细节的,如果要写肯定还是有很多可以写的。

不过整体脉络都理清楚了,之后的修行还是得靠大家自己多多努力。

面试题肯定不止这一些,面试题是问不完的,真实的面试肯定是抓住你回答的点来深挖,所以我也模拟不了,我只能告诉你大致关键点,和揣摩一下面试官的心理活动。

当面试官问你的时候你可以试着去揣摩,看看他到底想要问什么,这很关键

面试的时候不要慌,你和面试官是平等的,而且面试官不一定你厉害,还有面试有时候就是看运气了,面试失败了也不要气馁,换一家就好了,有时候就是气场不和,这很正常。

加油。

查看原文

赞 14 收藏 8 评论 0

敖丙 发布了文章 · 2020-09-16

Dubbo服务引入过程

上篇文章我们已经了解了 Dubbo 服务暴露全过程,这篇文章我就带着大家再来看看 Dubbo 服务引入全流程,这篇服务引入写完下一篇就要来个全链路打通了,看看大家看完会不会有种任督二脉都被打通的感觉。

在写文章的过程中丙还发现官网的一点小问题,下文中会提到。

话不多说,咱们直接进入正题。

服务引用大致流程

我们已经得知 Provider将自己的服务暴露出来,注册到注册中心,而 Consumer 无非就是通过一波操作从注册中心得知 Provider 的信息,然后自己封装一个调用类和 Provider 进行深入地交流。

而之前的文章我都已经提到在 Dubbo 中一个可执行体就是 Invoker,所有调用都要向 Invoker 靠拢,因此可以推断出应该要先生成一个 Invoker,然后又因为框架需要往不侵入业务代码的方向发展,那我们的 Consumer 需要无感知的调用远程接口,因此需要搞个代理类,包装一下屏蔽底层的细节。

整体大致流程如下:

服务引入的时机

服务的引入和服务的暴露一样,也是通过 spring 自定义标签机制解析生成对应的 Bean,Provider Service 对应解析的是 ServiceBean 而 Consumer Reference 对应的是 ReferenceBean

前面服务暴露的时机我们上篇文章分析过了,在 Spring 容器刷新完成之后开始暴露,而服务的引入时机有两种,第一种是饿汉式,第二种是懒汉式。

饿汉式是通过实现 Spring 的 InitializingBean 接口中的 afterPropertiesSet 方法,容器通过调用 ReferenceBean afterPropertiesSet 方法时引入服务。

懒汉式是只有当这个服务被注入到其他类中时启动引入流程,也就是说用到了才会开始服务引入。

默认情况下,Dubbo 使用懒汉式引入服务,如果需要使用饿汉式,可通过配置 <dubbo:reference> 的 init 属性开启。

我们可以看到 ReferenceBean 还实现了 FactoryBean 接口,这里有个关于 Spring 的面试点我带大家分析一波。

BeanFactory 、FactoryBean、ObjectFactory

就是这三个玩意,我单独拿出来说一下,从字面上来看其实可以得知 BeanFactoryObjectFactory 是个工厂而 FactoryBean 是个 Bean。

BeanFactory 其实就是 IOC 容器,有多种实现类我就不分析了,简单的说就是 Spring 里面的 Bean 都归它管,而 FactoryBean 也是 Bean 所以说也是归 BeanFactory 管理的。

FactoryBean 到底是个什么 Bean 呢?它其实就是把你真实想要的 Bean 封装了一层,在真正要获取这个 Bean 的时候容器会调用 FactoryBean#getObject() 方法,而在这个方法里面你可以进行一些复杂的组装操作。

这个方法就封装了真实想要的对象复杂的创建过程

到这里其实就很清楚了,就是在真实想要的 Bean 创建比较复杂的情况下,或者是一些第三方 Bean 难以修改的情形,使用 FactoryBean 封装了一层,屏蔽了底层创建的细节,便于 Bean 的使用。

而 ObjectFactory 这个是用于延迟查找的场景,它就是一个普通工厂,当得到 ObjectFactory 对象时,相当于 Bean 没有被创建,只有当 getObject() 方法时,才会触发 Bean 实例化等生命周期。

主要用于暂时性地获取某个 Bean Holder 对象,如果过早的加载,可能会引起一些意外的情况,比如当 Bean A 依赖 Bean B 时,如果过早地初始化 A,那么 B 里面的状态可能是中间状态,这时候使用 A 容易导致一些错误。

总结的说 BeanFactory 就是 IOC 容器,FactoryBean 是特殊的 Bean, 用来封装创建比较复杂的对象,而 ObjectFactory 主要用于延迟查找的场景,延迟实例化对象

服务引入的三种方式

服务的引入又分为了三种,第一种是本地引入、第二种是直接连接引入远程服务、第三种是通过注册中心引入远程服务。

本地引入不知道大家是否还有印象,之前服务暴露的流程每个服务都会通过搞一个本地暴露,走 injvm 协议(当然你要是 scope = remote 就没本地引用了),因为存在一个服务端既是 Provider 又是 Consumer 的情况,然后有可能自己会调用自己的服务,因此就弄了一个本地引入,这样就避免了远程网络调用的开销。

所以服务引入会先去本地缓存找找看有没有本地服务

直连远程引入服务,这个其实就是平日测试的情况下用用,不需要启动注册中心,由 Consumer 直接配置写死 Provider 的地址,然后直连即可。

注册中心引入远程服务,这个就是重点了,Consumer 通过注册中心得知 Provider 的相关信息,然后进行服务的引入,这里还包括多注册中心,同一个服务多个提供者的情况,如何抉择如何封装,如何进行负载均衡、容错并且让使用者无感知,这就是个技术活。

本文用的就是单注册中心引入远程服务,让我们来看看 Dubbo 是如何做的吧。

服务引入流程解析

默认是懒汉式的,所以服务引入的入口就是 ReferenceBean 的 getObject 方法。

可以看到很简单,就是调用 get 方法,如果当前还没有这个引用那么就执行 init 方法。

官网的一个小问题

这个问题就在 if (ref == null) 这一行,其实是一位老哥在调试的时候发现这个 ref 竟然不等于 null,因此就进不到 init 方法里面调试了,后来他发现是因为 IDEA 为了显示对象的信息,会通过 toString 方法获取对象对应的信息。

toString 调用的是 AbstractConfig#toString,而这个方法会通过反射调用了 ReferenceBean 的 getObject 方法,触发了引入服务动作,所以说到断点的时候 ref != null

可以看到是通过方法名来进行反射调用的,而 getObject 就是 get 开头的,因此会被调用。

所以这个哥们提了个 PR,但是一开始没有被接受,一位 Member 认为这不是 bug, idea 设置一下不让调用 toString 就好了。

不过另一位 Member 觉得这个 PR 挺好的,并且 Dubbo 项目二代掌门人北纬30也发话了,因此这个 PR 被受理了。

至此我们已经知道这个小问题了,然后官网上其实也写的很清楚。

但是小问题来了,之前我在文章提到我的源码版本是 2.6.5,是在 github 的 releases 里面下的,这个 tostring 问题其实我挺早之前就知道了,我想的是我 2.6.5 稳的一批,谁知道翻车了。

我调试的时候也没进到 init 方法因为 ref 也没等于 null,我就奇怪了,我里面去看了下 toString 方法,2.6.5版本竟然没有修改?没有将 getObject 做过滤,因此还是被调用了。

我又打开了2.7.5版本的代码,发现是修改过的判断。

我又去特意下了 2.6.6 版本的代码,发现也是修改过的,因此这个修改并不是随着 2.6.5 版本发布,而是 2.6.6,除非我下的是个假包,这就是我说的小问题了,不过影响不大。

其实提到这一段主要想说的是那个 PR,作为一个开源软件的输出者,很多细节也是很重要的,这个问题其实很影响源码的调试,因为对代码不熟,肯定会一脸懵逼,谁知道是不是哪个后台线程异步引入了呢。

提这个 PR 的老哥花了两个小时才搞清楚真正的原因,所以说虽然这不是个 bug 但是很影响那些想深入了解 Dubbo 内部结构的同学们,这种改配置去适应的方案是不可取了,还好最终的方案是改代码。

好了让我们回到今天的主题,接下来分析的就是那个不让我进去的 init 方法了。

源码分析

init 方法很长,不过大部分就是检查配置然后将配置构建成 map ,这一大段我就不分析了,我们直接看一下构建完的 map 长什么样。

然后就进入重点方法 createProxy,从名字可以得到就是要创建的一个代理,因为代码很长,我就一段一段的分析

如果是走本地的话,那么直接构建个走本地协议的 URL 然后进行服务的引入,即 refprotocol.refer,这个方法之后会做分析,本地的引入就不深入了,就是去之前服务暴露的 exporterMap 拿到服务。

如果不是本地,那肯定是远程了,接下来就是判断是点对点直连 provider 还是通过注册中心拿到 provider 信息再连接 provider 了,我们分析一下配置了 url 的情况,如果配置了 url 那么不是直连的地址,就是注册中心的地址。

然后就是没配置 url 的情况,到这里肯定走的就是注册中心引入远程服务了。

最终拼接出来的 URL 长这样。

可以看到这一部分其实就是根据各种参数来组装 URL ,因为我们的自适应扩展都需要根据 URL 的参数来进行的。

至此我先画个图,给大家先捋一下。

这其实就是整个流程了,简述一下就是先检查配置,通过配置构建一个 map ,然后利用 map 来构建 URL ,再通过 URL 上的协议利用自适应扩展机制调用对应的 protocol.refer 得到相应的 invoker 。

在有多个 URL 的时候,先遍历构建出 invoker 然后再由 StaticDirectory 封装一下,然后通过 cluster 进行合并,只暴露出一个 invoker 。

然后再构建代理,封装 invoker 返回服务引用,之后 Comsumer 调用的就是这个代理类。

相信通过图和上面总结性的简述已经知道大致的服务引入流程了,不过还是有很多细节,比如如何从注册中心得到 Provider 的地址,invoker 里面到底是怎么样的?别急,我们继续看。

从前面的截图我们可以看到此时的协议是 registry 因此走的是 RegistryProtocol#refer,我们来看一下这个方法。

主要就是获取注册中心实例,然后调用 doRefer 进行真正的 refer。

这个方法很关键,可以看到生成了 RegistryDirectory 这个 directory 塞了注册中心实例,它自身也实现了 NotifyListener 接口,因此注册中心的监听其实是靠这家伙来处理的

然后向注册中心注册自身的信息,并且向注册中心订阅了 providers 节点、 configurators 节点 和 routers 节点,订阅了之后 RegistryDirectory 会收到这几个节点下的信息,就会触发 DubboInvoker 的生成了,即用于远程调用的 Invoker

然后通过 cluster 再包装一下得到 Invoker,因此一个服务可能有多个提供者,最终在 ProviderConsumerRegTable 中记录这些信息,然后返回 Invoker。

所以我们知道 Conusmer 是在 RegistryProtocol#refer 中向注册中心注册自己的信息,并且订阅 Provider 和配置的一些相关信息,我们看看订阅返回的信息是怎样的。

拿到了 Provider 的信息之后就可以通过监听触发 DubboProtocol# refer 了(具体调用哪个 protocol 还是得看 URL的协议的,我们这里是 dubbo 协议),整个触发流程我就不一一跟一下了,看下调用栈就清楚了。

终于我们从注册中心拿到远程 Provider 的信息了,然后进行服务的引入。

这里的重点在 getClients,因为终究是要跟远程服务进行网络调用的,而 getClients 就是用于获取客户端实例,实例类型为 ExchangeClient,底层依赖 Netty 来进行网络通信,并且可以看到默认是共享连接。

getSharedClient 我就不分析了,就是通过远程地址找 client ,这个 client 还有引用计数的功能,如果该远程地址还没有 client 则调用 initClient,我们就来看一下 initClient 方法。

而这个 connect 最终返回 HeaderExchangeClient 里面封装的是 NettyClient

然后最终得到的 Invoker 就是这个样子,可以看到记录的很多信息,基本上该有的都有了,我这里走的是对应的服务只有一个 url 的情况,多个 url 无非也是利用 directorycluster 再封装一层。

最终将调用 return (T) proxyFactory.getProxy(invoker); 返回一个代理对象,这个就不做分析了。

到这里,整个流程就是分析完了,不知道大家清晰了没?我再补充前面的图,来一个完整的流程给大家再过一遍。

小结

相信分析下来整个流程不难的,总结地说无非就是通过配置组成 URL ,然后通过自适应得到对于的实现类进行服务引入,如果是注册中心那么会向注册中心注册自己的信息,然后订阅注册中心相关信息,得到远程 provider 的 ip 等信息,再通过 netty 客户端进行连接。

并且通过 directorycluster 进行底层多个服务提供者的屏蔽、容错和负载均衡等,这个之后文章会详细分析,最终得到封装好的 invoker 再通过动态代理封装得到代理类,让接口调用者无感知的调用方法。

最后

今天这篇文章看下来相信大家对服务的引入应该有了清晰的认识,其实里面还是很多细节我没有展开分析,比如一些过滤链的组装,这其实在服务暴露的文章里面已经说了,同样服务引用也有过滤链,不过篇幅有限就不展开了,抓住主线要紧。

至此我已经带大家先过了一遍 Dubbo 的整体概念和大致流程,介绍了 Dubbo SPI 机制,并且分析了服务的暴露流程服务引入流程,具体的细节还是得大家自己去摸索,大致的流程我都讲的差不多了。

dubbo系列也快接近尾声了,虽然我知道每次写硬核技术看的小伙伴就少了很多,但是还是想写完这个系列,感谢大家的支持。

我是敖丙,你知道的越多,你不知道的越多,我们下期见!

人才们的 【三连】 就是敖丙创作的最大动力,如果本篇博客有任何错误和建议,欢迎人才们留言!


查看原文

赞 4 收藏 3 评论 0

认证与成就

  • 认证信息 算法工程师
  • 获得 1604 次点赞
  • 获得 4 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • Java面试

    互联网一线大厂Java 工程师面试+学习指南

注册于 2019-10-29
个人主页被 27.9k 人浏览