01 结构梳理:大并发下,你的数据库表可能成为性能隐患 你好,我是徐长龙,欢迎进入第一章节的学习。

这一章我们主要讲解怎么对读多写少的系统进行高并发优化,我会拿用户中心作为例子,带你来看改造的几个要点。

用户中心是一个典型的读多写少系统,可以说我们大部分的系统都属于这种类型,而这类系统通过缓存就能获得很好的性能提升。并且在流量增大后,用户中心通常是系统改造中第一个要优化的模块,因为它常常和多个系统重度耦合,所以梳理这个模块对整个系统后续的高并发改造非常重要。

今天这节课,我会带你对读多写少的用户中心做数据整理优化,这会让数据更容易缓存。数据梳理是一个很重要的技巧,任何老系统在做高并发改造时都建议先做一次表的梳理。

因为老系统在使用数据库的时候存在很多问题,比如实体表字段过多、表查询维度和用途多样、表之间关系混乱且存在m:n情况……这些问题会让缓存改造十分困难,严重拖慢改造进度。

如果我们从数据结构出发,先对一些场景进行改造,然后再去做缓存,会让之后的改造变得简单很多。所以先梳理数据库结构,再对系统进行高并发改造是很有帮助的

这节课我会给你讲几个具体的规律和思路,帮助你快速判断当前的表结构是否适用于高并发场景,方便后续的系统升级和改造。

精简数据会有更好的性能

为了方便讨论,我先对用户中心做一些简单介绍,如图:

用户中心的主要功能是维护用户信息、用户权限和登录状态,它保存的数据大部分都属于读多写少的数据。用户中心常见的优化方式主要是将用户中心和业务彻底拆开,不再与业务耦合,并适当增加缓存来提高系统性能。

我举一个简单的例子:当时整表内有接近2000万的账号信息,我对表的功能和字段进行了业务解耦和精简,让用户中心的账户表里只会保留用户登陆所需的账号、密码: CREATE TABLE account (   id int(10) NOT NULL AUTO_INCREMENT,   account char(32) COLLATE utf8mb4_unicode_ci NOT NULL,   password char(32) COLLATE utf8mb4_unicode_ci NOT NULL,   salt char(16) COLLATE utf8mb4_unicode_ci NOT NULL,   status tinyint(3) NOT NULL DEFAULT ‘0’,   update_time int(10) NOT NULL,   create_time int(10) NOT NULL,   PRIMARY KEY (id),   UNIQUE KEY login_account (account) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

我们知道数据库是系统的核心,如果它缓慢,那么我们所有的业务都会受它影响,我们的服务很少能超过核心数据库的性能上限。而我们减少账号表字段的核心在于,长度小的数据在吞吐、查询、传输上都会很快,也会更好管理和缓存。

精简后的表拥有更少的字段,对应的业务用途也会比较单纯。其业务主要功能就是检测用户登陆账号密码是否正确,除此之外平时不会有其他访问,也不会被用于其他范围查询上。可想而知这种表的性能一定极好,虽然存储两千万账号,但是整体表现很不错。

不过你要注意,精简数据量虽然能换来更好的响应速度,但不提倡过度设计。因为表字段如果缺少冗余会导致业务实现更为繁琐,比如账户表如果把昵称和头像删减掉,我们每次登录就需要多读取一次数据库,并且需要一直关注账户表的缓存同步更新;但如果我们在账户表中保留用户昵称和头像,在登陆验证后直接就可以继续其他业务逻辑了,无需再查询一次数据库。

所以你看,有些查询往往会因为精简一两个字段就多查一次数据库,并且还要考虑缓存同步问题,实在是得不偿失,因此我们要在“更多的字段”和“更少的职能”之间找到平衡。

数据的归类及深入整理

除了通过精简表的职能来提高表的性能和维护性外,我们还可以针对不同类型的表做不同方向的缓存优化,如下图用户中心表例子:

图片

数据主要有四种:实体对象主表、辅助查询表、实体关系和历史数据,不同类型的数据所对应的缓存策略是不同的,如果我们将一些职能拆分不清楚的数据硬放在缓存中,使用的时候就会碰到很多烧脑的问题。

我之前就碰到过这样的错误做法——将用户来访记录这种持续增长的操作历史放到缓存里,这个记录的用途是统计有多少好友来访、有多少陌生人来访,但它同时保存着和用户是否是好友的标志。这也就意味着,一旦用户关系发生变化,这些历史数据就需要同步更新,否则里面的好友关系就“过时”了。

将历史记录和需要实时更新的好友状态混在一起,显然不合理。如果我们做归类梳理的话,应该拆分成三个职能表,分别进行管理:

  • 历史记录表,不做缓存,仅展示最近几条,极端情况临时缓存;
  • 好友关系(缓存关系,用于统计有几个好友);
  • 来访统计数字(临时缓存)。

明白了数据归类处理的重要性后,我们接下来分别看看如何对上述四种类型的数据做缓存优化。

数据实体表

先看一下用户账号表,这个表是一个实体表,实体表一般会作为主表 ,它的一行数据代表一个实体,每个实体都拥有一个独立且唯一的ID作为标识。其中,“实体”代表一个抽象的事物,具体的字段表示的是当前实体实时的状态属性。

这个ID对于高并发环境下的缓存很重要,用户登录后就需要用自己账户的ID直接查找到对应的订单、昵称头像和好友列表信息。如果我们的业务都是通过这样的方式查找,性能肯定很好,并且很适合做长期缓存。

但是业务除了按ID查找外,还有一些需要通过组合条件查询的,比如:

  • 在7月4日下单购买耳机的订单有哪些?
  • 天津的用户里有多少新注册的用户?有多少老用户?
  • 昨天是否有用户名前缀是rick账户注册?

这种根据条件查询统计的数据是不太容易做缓存的,因为高并发服务缓存的数据通常是能够快速通过Hash直接匹配的数据,而这种带条件查询统计的数据很容易出现不一致、数据量不确定导致的性能不稳定等问题,并且如果涉及的数据出现变化,我们很难通过数据确定同步更新哪些缓存。

因此,这类数据只适合存在关系数据库或提前预置计算好结果放在缓存中直接使用,做定期更新。

除了组合条件查询不好缓存外,像 count() 、sum() 等对数据进行实时计算也有更新不及时的问题,同样只能定期缓存汇总结果,不能频繁查询。所以,我们应该在后续的开发过程中尽量避免使用数据库做计算。

回到刚才的话题,我们继续讨论常见的数据实体表的设计。其实这类表是针对业务的主要查询需求而设计的,如果我们没有按照这个用途来查询表的时候,性能往往会很差。

比如前面那个用于账户登录的表,当我们拿它查询用户昵称中是否有“极客”两个字的时候,需要做很多额外的工作,需要对“用户昵称”这个字段增加索引,同时这种like查询会扫描全表数据进行计算。

如果这种查询的频率比较高,就会严重影响其他用户的登陆,而且新增的昵称索引还会额外降低当前表插入数据的性能,这也是为什么我们的后台系统往往会单独分出一个从库,做特殊索引。

一般来说,高并发用缓存来优化读取的性能时,缓存保存的基本都是实体数据。那常见的方法是先通过“key前缀 + 实体ID”获取数据(比如user_info_9527),然后通过一些缓存中的关联关系再获取指定数据,比如我们通过ID就可以直接获取用户好友关系key,并且拿到用户的好友ID列表。通过类似的方式,我们可以在Redis中实现用户常见的关联查询操作。

总体来说,实体数据是我们业务的主要承载体,当我们找到实体主体的时候,就可以根据这个主体在缓存中查到所有和它有关联的数据,来服务用户。现在我们来稍微总结一下,我们整理实体表的核心思路主要有以下几点:

  • 精简数据总长度;
  • 减少表承担的业务职能;
  • 减少统计计算查询;
  • 实体数据更适合放在缓存当中;
  • 尽量让实体能够通过ID或关系方式查找;
  • 减少实时条件筛选方式的对外服务。

下面我们继续来看另外三种表结构,你会发现它们不太适合放在缓存中,因为维护它们的一致性很麻烦。

实体辅助表

为了精简数据且方便管理,我们经常会根据不同用途对主表拆分,常见的方式是做纵向表拆分

纵向表拆分的目的一般有两个,一个是把使用频率不高的数据摘出来。常见主表字段很多,经过拆分,可以精简它的职能,而辅助表的主键通常会保持和主表一致或通过记录ID进行关联,它们之间的常见关系为1:1。

而放到辅助表的数据,一般是主要业务查询中不会使用的数据,这些数据只有在极个别的场景下才会取出使用,比如用户账号表为主体用于做用户登陆使用,而辅助信息表保存家庭住址、省份、微信、邮编等平时不会展示的信息。

辅助表的另一个用途是辅助查询,当原有业务数据结构不能满足其他维度的实体查询时,可以通过辅助表来实现

比如有一个表是以“教师”为主体设计的,每次业务都会根据“当前教师ID+条件”来查询学生及班级数据,但从学生的角度使用系统时,需要高频率以“学生和班级”为基础查询教师数据时,就只能先查出 “学生ID”或“班级ID”,然后才能查找出老师ID”,这样不仅不方便,而且还很低效,这时候就可以把学生和班级的数据拆分出来,额外做一个辅助表包含所有详细信息,方便这种查询。

另外,我还要提醒一下,因为拆分的辅助表会和主体出现1:n甚至是m:n的数据关系,所以我们要定期地对数据整理核对,通过这个方式保证我们冗余数据的同步和完整。

不过,非1:1数据关系的辅助表维护起来并不容易,因为它容易出现数据不一致或延迟的情况,甚至在有些场景下,还需要刷新所有相关关系的缓存,既耗时又耗力。如果这些数据的核对通过脚本去定期执行,通过核对数据来找出数据差异,会更简单一些。

此外,在很多情况下我们为了提高查询效率,会把同一个数据冗余在多个表内,有数据更新时,我们需要同步更新冗余表和缓存的数据。

这里补充一点,行业里也会用一些开源搜索引擎,辅助我们做类似的关系业务查询,比如用ElasticSearch做商品检索、用OpenSearch做文章检索等。这种可横向扩容的服务能大大降低数据库查询压力,但唯一缺点就是很难实现数据的强一致性,需要人工检测、核对两个系统的数据。

实体关系表

接下来我们再谈谈实体之间的关系。

图片

在关系类型数据中,我强烈建议额外用一个关系表来记录实体间m:n的关联关系,这样两个实体就不用因为相互依赖关系,导致难以维护。

在对1:n或m:n关系的数据做缓存时,我们建议提前预估好可能参与的数据量,防止过大导致缓存缓慢。同时,通常保存这个关系在缓存中会把主体的ID作为key,在value内保存多个关联的ID来记录这两个数据的关联关系。而对于读取特别频繁的的业务缓存,才会考虑把数据先按关系组织好,然后整体缓存起来,来方便查询和使用。

需要注意的是,这种关联数据很容易出现多级依赖,会导致我们整理起来十分麻烦。当相关表或条件更新的时候,我们需要及时同步这些数据在缓存中的变化。所以,这种多级依赖关系很难在并发高的系统中维护,很多时候我们会降低一致性要求来满足业务的高并发情况。

总的来说,只有通过ID进行关联的数据的缓存是最容易管理的,其他的都需要特殊维护,我会在下节课给你介绍怎么维护缓存的更新和一致性,这里就不展开说了。

现在我们简单总结一下,到底什么样的数据适合做缓存。一般来说,根据ID能够精准匹配的数据实体很适合做缓存;而通过String、List或Set指令形成的有多条value的结构适合做(1:1、1:n、m:n)辅助或关系查询;最后还有一点要注意,虽然Hash结构很适合做实体表的属性和状态,但是Hgetall指令性能并不好,很容易让缓存卡顿,建议不要这样做。

图片

动作历史表

介绍到这里,我们已经完成了大部分的整理,同时对于哪些数据可以做缓存,你也有了较深理解。为了加深你的印象,我再介绍一些反例。

一般来说,动作历史数据表记录的是数据实体的动作或状态变化过程,比如用户登陆日志、用户积分消费获取记录等。这类数据会随着时间不断增长,它们一般用于记录、展示最近信息,不建议用在业务的实时统计计算上。

你可能对我的这个建议存有疑虑,我再给你举个简单的例子。如果我们要从一个有2000万条记录的积分领取记录表中,检测某个用户领取的ID为15的商品个数: CREATE TABLE user_score_history ( id int(10) unsigned NOT NULL AUTO_INCREMENT, uid int(10) NOT NULL DEFAULT ‘’, action varchar(32) NOT NULL, action_id char(16) NOT NULL, status tinyint(3) NOT NULL DEFAULT ‘0’ extra TEXT NOT NULL DEFAULT ‘’, update_time int(10) NOT NULL DEFAULT ‘0’, create_time int(10) NOT NULL DEFAULT ‘0’, PRIMARY KEY (id), KEY uid(uid,action), ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; select uid, count(/*) as action_count, product_id from user_score_history where uid = 9527 and action = “fetch_gift” and action_id = 15 and status = 1 group by uid,action_id

不难看出,这个表数据量很大,记录了大量的实体动作操作历史,并且字段和索引不适合做这种查询。当我们要计算某个用户领取的ID为15的商品个数,只能先通过UID索引过滤数据,缩小范围。但是,这样筛选出的数据仍旧会很大。并且随着时间的推移,这个表的数据会不断增长,它的查询效率会逐渐降低。

所以,对于这种基于大量的数据统计后才能得到的结论数据,我不建议对外提供实时统计计算服务,因为这种查询会严重拖慢我们的数据库,影响服务稳定。即使使用缓存临时保存统计结果,这也属于临时方案,建议用其他的表去做类似的事情,比如实时查询领取记录表,效果会更好。

总结

在项目初期,数据表的职能设计往往都会比较简单,但随着时间的推移和业务的发展变化,表经过多次修改后,其使用方向和职能都会发生较大的变化,导致我们的系统越来越复杂。

所以,当流量超过数据库的承受能力需要做缓存改造时,我们建议先根据当前的业务逻辑对数据表进行职能归类,它能够帮你快速识别出,表中哪些字段和功能不适合在特定类型的表内使用,这会让数据在缓存中有更好的性价比。

一般来说,数据可分为四类:实体表、实体辅助表、关系表和历史表,而判断是否适合缓存的核心思路主要是以下几点:

  • 能够通过ID快速匹配的实体,以及通过关系快速查询的数据,适合放在长期缓存当中;
  • 通过组合条件筛选统计的数据,也可以放到临时缓存,但是更新有延迟;
  • 数据增长量大或者跟设计初衷不一样的表数据,这种不适合、也不建议去做做缓存。

图片

思考题

请你思考一下,用户邀请其他用户注册的记录,属于历史记录还是关系记录?

欢迎你在留言区与我交流讨论,我们下节课见!

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e9%ab%98%e5%b9%b6%e5%8f%91%e7%b3%bb%e7%bb%9f%e5%ae%9e%e6%88%98%e8%af%be/01%20%e7%bb%93%e6%9e%84%e6%a2%b3%e7%90%86%ef%bc%9a%e5%a4%a7%e5%b9%b6%e5%8f%91%e4%b8%8b%ef%bc%8c%e4%bd%a0%e7%9a%84%e6%95%b0%e6%8d%ae%e5%ba%93%e8%a1%a8%e5%8f%af%e8%83%bd%e6%88%90%e4%b8%ba%e6%80%a7%e8%83%bd%e9%9a%90%e6%82%a3.md