大麦网业务分析-1

数据库

分库分表

分库分表是解决海量数据存储和高并发访问的有效手段,但其设计并非一蹴而就,需要深入思考以下几个核心原则和复杂性。

分片键—-重要

分片键是数据分布的依据,其选择直接决定了分片策略的有效性和未来的可维护性。

  • 业务相关性: 分片键应与核心业务逻辑紧密相关。例如,用户相关数据以 user_id 分片,订单相关数据可能也以 user_id 分片以保证用户维度的数据局部性。
  • 数据均匀性: 理想的分片键能够将数据均匀地分散到各个分片中,避免出现“热点”(某个分片数据量过大或访问压力过高)。通常采用哈希(如 MD5(user_id) % N 或更复杂的哈希算法)或自定义算法来确保均匀分布。

可以通过历史数据分析来模拟不同分片键策略下的数据分布情况,评估其均匀性。对于哈希取模,可以考虑使用一致性哈希,它在节点增减时能更好地保持数据分布的均衡性,减少数据迁移量。

  • 稳定性: 分片键一旦确定,通常不应改变。如果分片键的值会频繁更新,会带来数据迁移和维护的巨大挑战。

    对于那些不含分片键的查询,需要特别关注。这类查询通常会触发全表扫描(或全分片扫描),性能极低。在设计业务功能时,应尽量引导业务方在查询条件中包含分片键。如果无法避免,则需要考虑其他方案,例如:

数据冗余: 将少量高频查询但无分片键的数据冗余到所有分片或单独的热点库中。

ES/Solr等搜索引擎: 对于复杂查询、模糊查询或多维度查询,可以考虑将部分数据同步到Elasticsearch或Solr等搜索引擎中,利用其强大的查询能力,将查询压力从关系型数据库中剥离。

数仓或OLAP系统: 对于报表、统计等分析类查询,应将其导入到数仓或OLAP系统中进行处理,避免直接冲击在线事务数据库。

  • 查询命中率: 绝大部分查询应该能够通过分片键直接路由到唯一的分片,避免跨分片查询,从而提高效率。
  • 数据类型: 通常选择整数类型(如 bigint)作为分片键,因为它们计算和比较效率高。

分片策略

水平分片 (Horizontal Sharding/Row-based Sharding): 将表中行数据分散到不同的物理表或数据库中。这是最常见的模式,也是您文件中的 _0, _1 后缀所体现的。

好处: 扩展性强,可以根据数据增长动态增加分片。查询通常只涉及少量数据,性能高。

常见实现:

  • 哈希取模: sharding_key % N(N为分片总数),数据分布最均匀,但扩容时需要数据迁移(弹性伸缩性差)。
  • 范围分片: 根据分片键的值范围划分,例如用户ID 1-100万在一个分片,100万-200万在另一个。优点是扩容方便(直接增加新范围分片),缺点是容易出现热点。
  • 一致性哈希: 一种更高级的哈希算法,在增加或减少分片时,只需要迁移少量数据,弹性伸缩性更好。
  • 时间分片: 例如按年或月创建新表,用于日志、流水等时间序列数据。

对于用户数据,哈希取模通常是首选,因为它能带来更好的均匀性。而对于按时间产生的日志或流水数据,时间分片则更为合适。一致性哈希: 这是一种更高级且更灵活的策略,值得深入研究和应用,尤其是在需要频繁扩容或缩容的场景。它能有效降低数据迁移的成本。

垂直分片 (Vertical Sharding/Column-based Sharding):

按业务功能分库: 将不同业务模块的数据(例如用户服务、订单服务、商品服务)分别存储在独立的数据库中。这通常是微服务架构中的常见实践。

  • 好处: 职责分离,团队独立开发维护,数据库之间互不影响,方便根据业务特点进行优化。
  • 例子: damai_base_data, damai_customize, damai_order_0/1, damai_pay_0/1, damai_program_0/1, damai_user_0/1 等数据库的划分,明显是按业务领域(基础数据、定制、订单、支付、节目、用户)进行的垂直分库。

按字段分表: 将一张表中不同业务含义或访问频率差异大的列拆分到不同的表中,以减少单表宽度,提高查询效率。解决大宽表问题上非常有效。过多的垂直分表可能会增加数据管理的复杂性。

  • 好处: 减少I/O开销,热点数据和非热点数据分离存储,提高查询效率。

在决定按字段分表时,应仔细分析字段的访问频率、更新频率以及是否包含大文本或二进制数据。通常将不常用的大字段、或者单独查询频率高的字段拆分出去。

数据路由

分片后,应用程序如何知道数据存在哪个分片上?这需要一个路由层。

  • 应用层路由: 应用程序代码根据分片键和分片算法直接计算出目标分片,然后连接到对应的数据库。实现简单,但应用逻辑与分片规则耦合。
  • 中间件路由: 使用专门的数据库中间件(如MyCAT, ShardingSphere, Vitess)作为代理层,应用程序连接中间件,中间件负责解析SQL、计算分片、路由请求、聚合结果。这是最推荐的方式,实现了分片逻辑与应用解耦,提供了更丰富的功能(如分布式事务、读写分离)。

ShardingSphere作为一套成熟的开源分布式数据库解决方案,提供了丰富的特性,包括数据分片、分布式事务、数据加密、影子库压测等,且支持多种部署模式,是目前非常受欢迎的选择。

MyCAT更偏向于一个数据库代理层,配置相对简单。

Vitess源自YouTube,更侧重于自动化运维和大规模部署,适合超大规模的场景。

全局唯一ID生成

在分库分表后,数据库的自增ID不能再使用,因为它们只能保证单库内的唯一性。需要引入全局唯一ID生成策略。

  • UUID/GUID: 优点是全局唯一,生成简单;缺点是无序,作为主键索引性能差,存储空间大。

虽然生成简单且全局唯一,但作为主键索引性能差是其致命缺点,因为其无序性导致B+树索引频繁分裂,影响写入性能和查询效率。应尽量避免作为主键。

  • 基于时间戳+机器ID+序列号: 如Snowflake算法,生成趋势递增的ID,有助于索引性能。

这是目前最推荐的方案。它生成的ID趋势递增,有利于数据库索引性能。同时,通过机器ID和序列号保证了全局唯一性。引入时钟回拨问题的应对策略。Snowflake算法依赖于系统时钟,如果系统时钟发生回拨,可能会生成重复ID或阻塞。需要有机制检测并处理这种情况(例如,等待时钟恢复或暂停ID生成服务)。

  • 独立ID生成服务: 单独部署一个服务来生成和分配ID。

部署一个独立的ID生成服务,可以提供更强的可控性和定制性,但增加了系统复杂度和维护成本。在没有特殊需求的情况下,Snowflake算法通常足以满足需求。

分布式事务

当一个业务操作涉及多个分片的数据修改时,需要保证这些操作的原子性(要么都成功,要么都失败)。

  • 2PC (Two-Phase Commit): 传统的关系型数据库分布式事务协议,但性能差,有阻塞风险。

理论上能保证强一致性,但由于其同步阻塞、性能低、易死锁等缺点,在线业务中极少使用,尤其是在高并发场景下。

  • TCC (Try-Confirm-Cancel): 业务层面的分布式事务解决方案,需要侵入业务代码,但性能较好。

是一种业务侵入性较强的柔性事务方案,需要业务开发人员在每个参与者服务中实现Try、Confirm、Cancel三个操作。 TCC适用于对实时性要求较高,且业务逻辑相对独立的场景。它的优点是性能比2PC好,但开发成本和维护成本较高,需要仔细设计补偿逻辑。

  • 消息最终一致性: 通过消息队列(MQ)实现异步补偿,保证最终一致性,适用于对实时性要求不高的场景。

通过可靠消息队列(如Kafka, RocketMQ)实现,将业务操作拆分为多个本地事务,通过消息通知其他服务进行后续操作。即使某个操作失败,也可以通过重试或补偿机制最终达到一致。

最终一致性,可能存在短暂的数据不一致窗口期;需要引入消息队列,增加系统复杂度。

在设计消息最终一致性方案时,需要考虑消息的幂等性消费消息的可靠投递(本地消息表/事务消息)、死信队列补偿机制

跨分片查询与Join

分片的主要目的是避免跨分片操作。如果业务逻辑不可避免地需要跨分片Join或聚合查询,会非常复杂和低效。

  • 尽量避免: 优化业务逻辑,将需要Join的数据放在同一个分片上(如关联表也使用相同的分片键)。
  • 应用层Join: 从各个分片查询数据,然后在应用层进行内存Join。
  • 数据冗余: 少量关键数据在不同分片上进行冗余存储,以避免跨分片Join。
  • 数据同步/ETL: 将需要Join的数据通过ETL同步到数仓或专门的分析数据库中进行分析。

尽量避免: 优化业务逻辑,使关联数据位于同一分片,这是最好的策略。

应用层Join: 适用于结果集较小的场景。如果涉及大量数据的跨分片Join,在应用层进行会消耗大量内存和CPU,导致性能瓶颈。

数据冗余: 对于读多写少、数据量不大且更新频率不高的关键关联数据,冗余是一种有效的优化手段。但需要考虑数据一致性问题,需要有机制(如消息队列异步同步)来保证冗余数据的一致性。

数据同步/ETL到数仓: 这是处理复杂跨分片查询和分析查询的标准方案。将事务性数据和分析性数据分离,可以避免分析查询对在线业务的影响,提高系统整体性能。

搭建一套完善的数据同步链路(例如基于Binlog的CDC工具,如Canal),将数据实时或准实时地同步到数仓(如Hive, Flink, StarRocks, ClickHouse等),并在数仓中进行复杂的Join和聚合分析。

数据迁移和扩容

当业务增长,分片容量不足时,需要进行扩容,这涉及数据迁移。这是一个复杂且风险高的操作,通常需要专门的工具和详细的预案。

预案先行: 必须制定详细的数据迁移预案,包括迁移策略、回滚方案、风险评估、影响范围、时间窗口等。

灰度发布/双写: 在数据迁移过程中,可以采用双写(新旧库同时写入)和灰度发布的方式,逐步将流量切换到新分片,以降低风险。

自动化工具: 尽量使用或开发自动化迁移工具,减少人工操作,提高效率和准确性。

监控与告警: 在迁移过程中,必须有完善的监控和告警机制,实时关注数据一致性、服务可用性和性能指标。

充分测试: 在生产环境迁移前,必须在类生产环境进行充分的全链路压测和功能测试,确保迁移方案的可靠性。

问题

用户服务分库分表与读扩散问题:

用户服务中用户邮箱、用户手机号多种方式登录,并且要避免读扩散,这是一个非常典型的应用场景。读扩散(Read Amplification)指的是为了获取一条数据,不得不查询多个甚至所有分片,从而导致数据库压力倍增和查询效率低下。

通过集中式映射服务,将多维度登录凭证映射到统一的分片键

为了避免读扩散,我们不会在用户数据分片中直接通过邮箱或手机号进行查询,而是会增加一个独立的用户身份映射层

独立的身份认证服务/库 :

该服务专门负责用户身份的认证和登录凭证(邮箱、手机号)到核心用户ID (user_id) 的映射。

d_user_main (主用户表,在用户数据分片中)

1
2
3
4
5
6
-- 在 damai_user_0, damai_user_1 等分片中
CREATE TABLE `d_user_0` (
`user_id` bigint(20) NOT NULL COMMENT '用户ID,作为主分片键',
-- 其他用户属性,如昵称、头像、注册时间等
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主表';

此表是用户核心数据表,以 user_id 作为分片键

d_ticket_user_X

1
2
3
4
5
6
7
8
9
10
-- 在 damai_user_0 分片中
CREATE TABLE `d_ticket_user_0` (
`id` bigint(20) NOT NULL COMMENT '主键id',
`user_id` bigint(20) NOT NULL COMMENT '用户id', -- 关联到 d_user_0 的 user_id,确保购票人信息和用户主信息在同一分片
`rel_name` varchar(256) NOT NULL COMMENT '用户真实名字',
`id_type` int(11) NOT NULL DEFAULT '1' COMMENT '证件类型',
`id_number` varchar(512) NOT NULL COMMENT '证件号码',
-- ... 其他字段
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购票人表';

独立的映射数据库/表

为了解决通过手机号/邮箱查找 user_id 的问题,我们需要额外的映射表。这些表通常存储在一个独立的、不分片的数据库中,或者在一个专用的、读写性能极高的存储系统(如Redis、Memcached)中,或者通过特定的哈希策略进行独立分片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 在一个独立的 Identity Database 或集中式数据库中
CREATE TABLE `user_email_mapping` (
`email_hash` varchar(64) NOT NULL COMMENT '邮箱的哈希值', -- 用于存储邮箱哈希,避免直接存储明文邮箱
`email` varchar(256) NOT NULL UNIQUE COMMENT '用户邮箱',
`user_id` bigint(20) NOT NULL COMMENT '对应的用户ID',
PRIMARY KEY (`email`),
KEY `idx_email_hash` (`email_hash`) -- 可选,如果查询以哈希为主
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='邮箱-用户ID映射表';

CREATE TABLE `user_mobile_mapping` (
`mobile_hash` varchar(64) NOT NULL COMMENT '手机号的哈希值', -- 存储手机号哈希
`mobile` varchar(20) NOT NULL UNIQUE COMMENT '用户手机号',
`user_id` bigint(20) NOT NULL COMMENT '对应的用户ID',
PRIMARY KEY (`mobile`),
KEY `idx_mobile_hash` (`mobile_hash`) -- 可选
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='手机号-用户ID映射表';

用户登录:

  1. 用户输入手机号/邮箱和密码。
  2. 身份认证服务接收请求。
  3. 身份认证服务首先查询 user_mobile_mappinguser_email_mapping(在独立的身份数据库中),通过手机号/邮箱快速查找对应的 user_id。这是一个单点查询,不会有读扩散。
  4. 获取 user_id 后,验证密码(密码哈希可以存储在身份数据库中,或与 user_id 关联存储在用户数据分片中的一个小表中)。
  5. 认证成功,生成登录凭证(如Token)。

业务数据访问:

  1. 用户携带登录凭证访问其他业务服务(如订单服务、商品服务)。
  2. 业务服务从凭证中解析出 user_id
  3. 根据 user_id 和预设的分片算法(例如 user_id % N),计算出该用户的数据存储在哪一个用户数据分片上。
  4. 所有后续针对该用户的业务数据查询(如查询订单、查询购票人信息等),都将直接路由到该特定的用户数据分片,无需查询其他分片