结构梳理:大并发下,你的数据库表可能成为性能隐患
用户中心是典型的读多写少系统,我们的许多系统也属于这种类型。这类系统通过引入缓存技术可以显著提升性能。在流量增大时,用户中心通常成为系统优化的首要模块,因为它通常与多个系统有高度耦合。因此,梳理和优化该模块对于整个系统的高并发改造至关重要。
我们将专注于优化读多写少的用户中心数据整理,使其更容易进行缓存。数据梳理是一项关键技能,对于任何需要高并发改造的老系统,建议先对数据库表进行梳理。老系统在使用数据库时,通常存在诸多问题,例如实体表字段过多、表查询维度和用途多样、表关系混乱,甚至存在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 快速匹配的,而带条件查询的统计数据容易出现不一致性和数据量不确定性,导致性能不稳定。此外,如果相关数据发生变化,我们也很难确定应该同步更新哪些缓存。
因此,这类数据更适合存放在关系数据库中,或者提前计算结果并放入缓存中进行使用,并定期更新。
除了组合条件查询难以缓存外,像 sum()
这类需要实时计算的操作也存在更新不及时的问题,只能定期缓存汇总结果,避免频繁查询。因此,在后续开发中,我们应尽量避免使用数据库来进行实时计算。
回到实体表的设计,这类表通常针对业务的主要查询需求而设计。如果我们偏离这个设计用途来查询表,性能往往会大打折扣。比如,用于账户登录的表,当我们用它来查询昵称中是否包含“极客”时,需要额外增加对“用户昵称”字段的索引。这类 LIKE
查询会扫描全表数据进行计算,并且若查询频率较高,可能会严重影响其他用户的登录体验。同时,增加的昵称索引会降低该表插入数据的性能,这也是为何在后台系统中,通常会单独分出一个从库,做特殊的索引查询。
在高并发场景中,为了优化读取性能,缓存通常用于保存实体数据。常见的方法是通过“key 前缀 + 实体 ID”获取数据(例如 user_info_9527
),然后利用缓存中的关联关系进一步获取指定数据。例如,通过 ID 直接获取用户好友关系的 key,从而获取用户好友 ID 列表。通过这种方式,我们可以在 Redis 中实现用户的常用关联查询操作。
总体来说,实体数据是我们业务的主要承载体,当我们找到实体主体的时候,就可以根据这个主体在缓存中查到所有和它有关联的数据,来服务用户。现在我们来稍微总结一下,我们整理实体表的核心思路主要有以下几点:精简数据总长度;减少表承担的业务职能;减少统计计算查询;实体数据更适合放在缓存当中;尽量让实体能够通过 ID 或关系方式查找;减少实时条件筛选方式的对外服务。
实体辅助表
为了精简数据并便于管理,我们常常根据不同用途对主表进行拆分,常见的方式是纵向表拆分。纵向表拆分的主要目的有两个:一是将使用频率较低的数据摘出来,以精简主表的职能;二是辅助表的主键通常与主表一致或通过记录 ID 关联,它们之间的关系多为 1:1。辅助表中保存的数据一般在主要业务查询中不使用,仅在特定场景下取用,比如用户账号表用于用户登录,而辅助信息表保存家庭住址、省份、微信和邮编等不常展示的信息。
辅助表的另一个作用是辅助查询。当原有业务数据结构无法满足其他维度的实体查询时,可以通过辅助表实现。例如,一个以“教师”为主体的表,通常根据“当前教师 ID + 条件”查询学生和班级数据。但当系统从学生的角度出发时,需要频繁以“学生和班级”为基础查询教师数据,这时就需先查出“学生 ID”或“班级 ID”,再查找教师 ID,既不方便又低效。因此,可以将学生和班级数据拆分到一个辅助表中,方便这些查询。
值得提醒的是,辅助表和主体表之间可能存在 1或 m的关系,因此我们需要定期整理和核对数据,以确保冗余数据的同步和完整。然而,维护非 1:1 数据关系的辅助表并不容易,容易导致数据不一致或延迟,有时还需刷新所有相关关系的缓存,既耗时又费力。通过脚本定期执行数据核对,找出差异会更加简单。此外,为提高查询效率,我们常常在多个表中冗余同一数据,数据更新时需同步更新冗余表和缓存。
行业内也常用一些开源搜索引擎辅助进行类似的关系业务查询,例如使用 ElasticSearch 进行商品检索,使用 OpenSearch 进行文章检索等。这些可横向扩容的服务能够显著减轻数据库查询压力,但其唯一缺点是很难实现数据的强一致性,因此需要人工检测和核对两个系统的数据。
实体关系表
接下来我们再谈谈实体之间的关系。
对于关系型数据,我强烈建议使用一个额外的关系表来记录实体间的 m
关联关系,这样两个实体无需相互依赖,更容易维护。对于 1:n或 m:n关系的数据缓存,建议提前评估可能涉及的数据量,防止缓存数据量过大影响性能。一般情况下,我们会用主体的 ID 作为缓存 key,value 中保存多个关联 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或关系方式查找,减少实时条件筛选对外服务。
-
辅助表数据不适合放在缓存中,维护一致性较为麻烦,需要定期核对和同步更新。
-
实体关系表的缓存管理复杂,需降低一致性要求以满足高并发情况。
-
根据ID能够精准匹配的数据实体适合做缓存,而通过String、List或Set指令形成的有多条value的结构适合做辅助或关系查询。
-
Hash结构适合做实体表的属性和状态,但Hgetall指令性能并不好,不建议使用。