写点什么

「数据密集型应用系统设计」读后感与团队高并发高性能实践案例

  • 2024-12-26
    北京
  • 本文字数:8148 字

    阅读完需:约 27 分钟

作者:京东物流 冯志文

一、分布式数据系统挑战

1.一致性(Consistency) :在多个节点上维护相同的数据副本,确保所有节点在任何给定时间点都能看到相同的数据状态。这是 CAP 理论中的 C 部分(一致性、可用性和分区容错性)。


2.可用性(Availability) :即使部分节点出现故障或网络分区,系统也要能够继续提供服务。这个问题与一致性相互冲突,因为为了提高可用性,可能需要牺牲一致性。


3.分区容错性(Partition Tolerance) :在网络分区情况下,系统仍然可以正常工作。网络分区可能会导致节点之间的通信中断,从而影响系统的整体性能和稳定性。


4.数据同步和复制:在多个节点上复制数据以提高可用性和减少单点故障带来的风险。但这引入了数据同步和一致性问题。


5.故障恢复和容错:当某个节点或组件发生故障时,系统需要能够自动检测并恢复到正常状态,或者在某种程度上继续运作。


6.扩展性和弹性:分布式系统应该能够根据需求灵活地扩展或收缩资源,以应对不断变化的负载。

二、理论篇

1)主从复制

1.1)为什么需要主从复制

简单来说,主从复制功能主要有以下三点作用。


1)读写分离


由于单台服务器可支持的能力有上限,故可部署 1 主 N 从,主库核心负责写,主从复制后,从库负责读(当然强一致性的比如财务金钱 读的还是主),以此提升中间件能力


2)数据容灾


任何服务器都有宕机的可能,同样可以通过主从复制功能提升中间件服务的可靠性;一旦主服务器宕机,可以立即将请求切换到从服务器,从而避免服务中断,继续提供服务。


3)分担主压力


比如 mysql 数据库大数据抽数,通过抽从库(数据量大),减轻主库压力


比如关闭 redis 主服务器持久化功能,由从服务器去执行持久化操作即可,以避免备份期间影响主服务器的服务。

1.2)mysql 主从复制

1.2.1)原理

主从复制步骤:


①Master 节点进行 insert、update、delete 操作时,会按顺序写入到 binlog 中。


②salve 从库连接 master 主库。


③当 Master 节点的 binlog 发生变化时,binlog dump 线程会通知所有的 salve 节点,并将相应的 binlog 内容推送给 slave 节点。


④I/O 线程接收到 binlog 内容后,将内容写入到本地的中继日志 relay-log。


⑤SQL thread 读取 I/O 线程写入的 relay-log,并且根据 relay-log 的内容对从数据库做对应的操作。


1.2.2)主从复制模式

1、同步复制



2、异步复制:mysql 默认的复制方式



3、半同步模式


1.2.3)主从复制 binlog 模式

MySQL 主从复制的 binlog 模式主要有以下几种:


1.基于语句的复制


◦在这种模式下,主库会将执行的每一条 SQL 语句记录到 binlog 中,然后从库会重新执行这些 SQL 语句。


◦优点:binlog 文件较小,适合大部分简单的 SQL 语句。


◦缺点:对于某些包含不确定性或依赖于环境的 SQL 语句(如NOW()UUID()),复制可能会出现不一致的情况。


2.基于行的复制


◦在这种模式下,主库会将每一行数据的变化记录到 binlog 中,而不是记录 SQL 语句本身。从库会直接应用这些行数据的变化。


◦优点:可以避免语句模式下由于某些 SQL 语句的不确定性导致的复制不一致问题,适用于复杂的 SQL 操作。


◦缺点:binlog 文件可能会变得非常大,特别是在进行批量更新或插入操作时。


3.混合模式复制


◦这种模式是 语句 和 行 的结合。在大部分情况下,MySQL 会使用 语句 模式,但在某些情况下(如无法保证语句在从库上执行结果一致时),会自动切换到 行 模式。


◦优点:结合了 语句 和 行 的优点,能够在大多数情况下保证复制的一致性和效率。


◦缺点:复杂性增加,可能需要更多的调试和监控。


具体选择哪种模式,通常取决于应用的具体需求和数据一致性的要求。



从 MySQL 5.7 开始,默认的binlog_format参数值是MIXED。在这种模式下,MySQL 会在大多数情况下使用基于语句的复制(SBR),但在某些需要更高一致性的情况下(例如,当语句包含不确定性或依赖于环境时),会自动切换到基于行的复制(RBR)。

1.3)Reids 主从复制

本章节摘抄自 Redis5 设计与源码分析


Redis 2.8 提出了新的主从复制解决方案。


主从复制(全量复制)流程图:



master 会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,master 和它所有的


slave 都维护了复制的数据下标 offset 和 master 的进程 id,因此,当网络连接断开后,slave 会请求 master


继续进行未完成的复制,从所记录的数据下标开始。如果 master 进程 id 变化了,或者从节点数据下标


offset 太旧,已经不在 master 的缓存队列里了,那么将会进行一次全量数据的复制。



主从复制初始化流程如图 21-1 所示。



从上图可以看到,当主服务器判断可以执行部分重同步时向从服务器返回"+CONTINUE";需要执行完整重同步时向从服务器返回"+FULLRESYNC RUN_ID OFFSET",其中 RUN_ID 为主服务器自己的运行 ID,OFFSET 为复制偏移量。


执行部分重同步的要求比较严格的:


1)RUN_ID 必须相等;


2)复制偏移量必须包含在复制缓冲区中。


在生产环境中,经常会出现以下两种情况:


·从服务器重启(复制信息丢失);


·主服务器故障导致主从切换(从多个从服务器重新选举出一台机器作为主服务器,主服务器运行 ID 发生改变)。


这时候无法执行部分重同步的,而这两种情况又很常见,因此 Redis 4.0 针对主从复制又提出了两点优化,提出了 psync2 协议。


方案 1:持久化主从复制信息。


Redis 服务器关闭时,将主从复制信息(复制的主服务器 RUN_ID 与复制偏移量)作为辅助字段存储在 RDB 文件中;


Redis 启动加载 RDB 文件,恢复主从复制信息,重新同步主服务器时携带持久化主从复制信息 ;


if(rdbSaveAuxFieldStrStr(rdb,"repl-id",server.replid)


== -1)return-1;


if(rdbSaveAuxFieldStrInt(rdb,"repl-offset",server.master_repl_offset)


== -1)return-1;


方案 2:存储上一个主服务器复制信息。


/ Replication (master) /


charreplid[CONFIG_RUN_ID_SIZE+1]; / My current replication ID. /


charreplid2[CONFIG_RUN_ID_SIZE+1]; /初始化 replid2 为空字符串/


long longmaster_repl_offset; / My current replication offset /


long longsecond_replid_offset; **/初始化-1. /


当主服务器发生故障,自己成为新的主服务器时,便使用 replid2 和 second_replid_offset 存储之前主服务器的运行 ID 与复制偏移量;


voidshiftReplicationId(void) {


memcpy(server.replid2,server.replid,sizeof(server.replid));


server.second_replid_offset = server.master_repl_offset+1;


changeReplicationId();


}


判断是否能执行部分重同步的条件也改变为:


if(strcasecmp(master_replid, server.replid) &&


(strcasecmp(master_replid, server.replid2) ||


psync_offset> server.second_replid_offset))


{ ...


gotoneed_full_resync;


}


假设 m 为主服务器(运行 ID 为 M_ID),A、B 和 C 为三个从服务器;某一时刻主服务器 m 发生故障,从服务器 A 升级为主服务器(同时会记录 replid2=M_ID),从服务器 B 和 C 重新向主服务器 A 发送"psync M_ID psync_offset"请求;显然根据上面条件,只要 psync_offset 满足条件,就可以执行部分重同步。

1.4)延迟复制问题

在使用延迟复制的数据库系统中,主从复制的数据传输并不是实时的,存在一定的延迟。这种延迟可能导致从服务器上的数据不是最新的,从而影响数据的一致性和系统的可靠性。以下是一些在使用延迟复制时需要注意的事项:


读写分离策略:在读写分离的系统中,确保关键的读取操作(如需要最新数据的查询)始终从主服务器读取,而非关键的读取操作可以从从服务器读取。


数据一致性要求:根据业务需求,明确哪些操作需要读取最新数据,哪些操作可以容忍一定的延迟。

2)数据分区

面单海量数据或者高并发场景的数据,主从复制技术还不够,还需要将数据拆分为多个分区。分区的目的是为了高可扩展性。

2.1)分区算法

取模

取模算法虽然使用简单,但对机器数量取模,在集群扩容和收缩时却有一定的局限性:因为在生产环境中根据业务量的大小,调整服务器数量是常有的事,而服务器数量 N 发生变化后 hash(key)%N 计算的结果也会随之变化!



比如:一个服务器节点挂了,计算公式从 hash(key)% 3 变成了 hash(key)% 2,结果会发生变化,此时想要访问一个 key,这个 key 的缓存位置大概率会发生改变,那么之前缓存 key 的数据也会失去作用与意义。


大量缓存在同一时间失效,造成缓存的雪崩,进而导致整个缓存系统的不可用,这基本上是不能接受的。为了解决优化上述情况,一致性 hash 算法应运而生~

Hash

散列是一种将输入数据(通常是键)转换为固定长度的值(通常是整数)的过程。这个固定长度的值称为哈希值。散列函数是执行这种转换的函数。常见的散列函数包括 MD5、SHA-256 以及更简单的 CRC16 等。


特点


•散列函数的输出是一个固定长度的哈希值。


•相同的输入总是会产生相同的输出。


•散列函数的设计目标是使不同的输入尽量均匀地分布到输出范围内,以减少冲突。


数据量和请求量分布均匀:使用散列函数将数据均匀分布到各个节点上,确保每个节点存储的数据量和处理的请求量大致相同,从而避免单个节点成为性能瓶颈。


扩容短板:当需要扩容时,增加或减少节点会导致大量数据需要重新分配,因为散列函数的结果会发生变化。这种情况下,几乎所有的数据都需要迁移到新的节点,导致扩容过程复杂且影响性能。

范围

扩容好:使用范围分片时,每个节点负责一定范围的数据。当需要扩容时,只需将新的范围分配给新节点,旧节点上的数据迁移量较小,扩容过程相对简单。


请求量不均匀:由于数据分布是基于范围的,如果某些范围的数据请求量特别大,会导致部分节点负载过高,而其他节点负载较低,造成请求量的不均匀分布。

一致性 Hash

2011 左右比较火的分布式缓存框架 memcache 就是使用的一致性 hash 算法。


平衡数据分布和扩容问题:一致性 Hash 通过将数据和节点都映射到一个虚拟的环上,使得每个节点只负责环上特定范围的数据。扩容时,只需将部分数据从现有节点迁移到新节点,迁移量较小,数据分布也相对均匀。


减少数据迁移:当添加或删除节点时,只需重新分配相邻节点的数据,大部分数据不需要移动,极大地减少了数据迁移量。


假设需要增加一台服务器 CS4,经过同样的 hash 运算,该服务器最终落于 t1 和 t2 服务器之间,具体如下图所示:



此时,只有 t1 和 t2 服务器之间的部分对象需要重新分配。在以上示例中只有 o3 对象需要重新分配,即它被重新到 CS4 服务器。


所以一致性哈希算法对于容错性和扩展性有非常好的支持。但一致性哈希算法也有一个严重的问题,就是数据倾斜


如果在分片的集群中,节点太少,并且分布不均,一致性哈希算法就会出现部分节点数据太多,部分节点数据太少。也就是说无法控制节点存储数据的分配。

哈希槽:散列和取模的结合

Redis 集群(Cluster)并没有选用上面一致性哈希,而是采用了哈希槽(SLOT)的这种概念。主要的原因就是上面所说的,一致性哈希算法对于数据分布、节点位置的控制并不是很友好。


1.散列函数:首先,使用散列函数 CRC16(key) 将键转换为一个哈希值。


2.取模运算:然后,对哈希值进行 16384 取模来得到具体槽位。HASH_SLOT = CRC16(key) mod 16384

2.2)分库分表

•个人建议能不分就不分,通过合适的索引,读写分离、冷热数据等方式,可以很好的解决性能问题:避免”过度设计"和"过早优化"


•数据量过大,正常运维已经影响到了业务访问的阶段才开始考虑分


分库分表有 2 种模式,分别如下


1.CLIENT 模式:Apache 开源社区的 ShardingSphere-JDBC、阿里的 TDDL


2.PROXY 模式:Apache 开源社区的 ShardingSphere-Proxy、公司弹性数据库 JED(京东弹性数据库,个人未实践过其分表功能),阿里的 cobar,MyCAT


核心的步骤基本都是一样的:SQL 解析,优化,路由,执行,结果归并。


mycat 架构图:



ShardingSphere 混合部署架构图:


2.3)扩容理想状态

最好不要数据迁移、无数据热点的问题


1)范围求模分片案例: 优点可以避免扩容时的数据迁移,又可以一定程度上避免范围分片的热点问题 1)比如数据库刚开始预估是 4000W 数据量,采用 2 个库 shard0、shard1。里面分别有 2 张表 Table_0,Table_1。通过 idhash2 分别对应不同 shard0、shard1 数据库库。里面数据量再通过范围比如 0-2000w 定位到 Table_0,2000-4000w 定位到 Table_1.


2)扩容(不迁移数据):比如数据量超过了 4000 万,需要扩容的时候,之前 0-4000 万数据保持不动,比如扩容到 1 个亿。则采用上面类似算法,把 6000-1 个亿的数据再次分布



3)热点:解决数据热点的问题(因为我们局部用了散列) 4)总结:1.多查一次数据库(字典表)2.依赖全局的 ID 生成

2.4)Redis 高可用集群


redis 集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性。Redis 集群不需要 sentinel 哨兵也能完成节点移除和故障转移的功能。需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展


Redis Cluster 将所有数据划分为 16384 个 slots(槽位),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息并将其缓存在客户端本地。这样当客户端要查找某个 key 时,可以直接定位到目标节点。同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整。


槽位定位算法


Cluster 默认会对 key 值使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。HASH_SLOT = CRC16(key) mod 16384


关于 Redis 集群选举原理、缓存穿透、雪崩、击穿等其他信息 网上很多资料,大家可搜索参考

三、实践篇-高并发高性能

1)数据库主从模式

采用 1 主 2 从模式,业务配置写主库,可延迟的导出或者查询采用读从库。如果对配置一致性比较强,则读主库。从库另外一个作用是大数据抽数,晚上抽数任务运行,但不影响主库



由于本身黄金链路不依赖 mysql 数据库,并且业务数据量在百万以下,离线数据千万及左右,主要用于大数据离线抽数表,过期数据及时结转,故没有采用分库分表策略。

踩坑案例:一条 delete 语句导致主从延迟问题

前提:mysql-binlog 模式是 row 现象: 在业务验证阶段,发现大数据抽数数据不全(数据库主库数据 2800W,大数据抽到 1500W 数据) alpha_aging_product_info_no_degrade_v2 表数据量 5700W(保留 2 天数据)


排查过程: 通过分析,发现大数据抽数的时候数据库从库数据 1500W 少了,但主库数据没少,主库数据是 2800 万。通过查看数据库监控,发现是主从延迟较长,延迟 11 小时(当时没配主从延迟报警)



找规律:观察是从 10.18 号开始延迟明显



经过分析操作记录如下: 1.10.8 号加的 delete 语句:delete from alpha_aging_product_info_no_degrade_v2 where create_time <'2022-10-19 00:00:00'; 主从延迟小于 30 分钟,可接受 2.10.18 号添加了一个组合索引(仓+地址) 3.10.18 号 delete 表数据的时候,导致主从延迟慢。 根本原因: 因为上面的 sql 增加了索引加剧了主从延迟,如果 delete 的数据是大量的数据,则会: 1.如果不加 limit 由于需要更新大量数据,从而索引失效变成全扫描导致锁表,同时由于修改大量的索引,产生大量的日志,导致这个更新会有很长时间,锁表锁很长时间,期间这个表无法处理线上业务。 2.由于产生了大量 binlog 导致主从同步压力变大。 3.由于标记删除产生了大量的存储碎片。由于 MySQL 是按页加载数据,这些存储碎片不仅大量增加了随机读取的次数,并且让页命中率降低,导致页交换增多。 改进点: 1.由于该数据库只为 promise 给路由推数不降级数据使用,数据库只有增加操作,故可让大数据抽主库 2.truncate table(VtDriver 驱动是不支持 truncate 语法),truncate 操作需要慎用,需要根据业务场景评估。 truncate 表都是高危操作,特别是在生产环境要更加小心,下面列出几点注意事项,希望大家使用时可以做下参考。 1.truncate 无法通过 binlog 回滚。 2.truncate 会清空所有数据且执行速度很快。 3.truncate 不能对有外键约束引用的表使用。 4.执行 truncate 需要 drop 权限,不建议给账号 drop 权限。 5.执行 truncate 前一定要再三检查确认,最好提前备份下表数据。 思考点: 1. MySQL 单表记录数过大,思考是否一定要用 MYSQL?比如 JDQ 数据传输等 2.当 MySQL 单表记录数过大时,增删改查性能都会急剧下降,任何的 sql 操作都不能根据常规思维去操作(比如加字段,加索引,删除语句,Select 查询语句等) 3.XBP 的 SQL 审批工单添加备注:表记录数,比如同一个 sql 表数据 10 万和 1 千万是不一样的。

2)大 key 治理

2.1)扫描大 key

大 KEY 带来的影响:


•严重影响 QPS、TP99 等指标,对大 Key 进行的慢操作会导致后续的命令被阻塞,从而导致一系列慢查询。


•大 Key 发生热点,大 String,value 大于 20K。当 OPS 为 10000,流量即为 200M, 达到单实例的流量配额. 导致无法正常提供服务。


集群各分片内存使用不均。某个分片占用内存较高或 OOM,发送缓存区增大等,导致该分片其他 Key 被逐出,同时也会造成其他分片的资源浪费。


•集群各分片的带宽使用不均。某个分片被流控,其他分片则没有这种情况,且影响宿主机上的其它应用。

2.2)大 key 改造

2.2.1)改造 list set zset hash 元素个数:1000

改造案例 1:清理 Hash 里面 field 过期数据,让大 key 瘦身


针对仓库产能大 key:iwpc:xxx:yyy:2:610:14:1:0 里面对应 field 是对应的每天日期比如 2024-01-1,故集合元素个数 1218 个是因为运行了 3 年多,由于存储的 Hash 结构缓存没有对过期的 filed 删除(如下图还存在 2023 年数据,这些数据已经无效)。由于 Redis 和 JIMDB 本身对 Hash(key,field,value)的 field 字段不支持自动过期。 需要代码判断并且 hDel(String key, String... fields)对过期的 field 删除。



代码改造


//首次个人设置清空历史1000+天数据//后面代码自动找到过去7天的fields日期,执行deleallFields.forEach( expireDay -> {        deleteCache(logPrefix,storeProductionKey,expireDay);});

private void deleteCache(String logPrefix,StoreProductionKey storeProductionKey, String day){ String key = storeProductionKey.generateConfigKey(); CallerInfo callerInfo = umpService.registerInfo(".XXX.deleteCache"); try{ redisClient.hDel(logPrefix, key, day); }catch (Exception e){ if(log.isErrorEnabled()){ log.error(logPrefix +"XXX" + key + day,e); } umpService.functionError(callerInfo); }finally { umpService.registerInfoEnd(callerInfo); }}
复制代码

2.2.2) 把大 key 变小 key

改造案例 2:重新定义唯一 key,把大 key 变小 key 背景:大宗时效数据,系统刚开始设计的 key 是 promise:control:${controlType} 备注:controlType 是订单类型,对应 value 是 Map<String, WhiteSkuTimeRangeCO> 其中 String 是 #{deliveryCenterId}:#{storeId)(对应配送中心+仓库 ID )


public class WhiteSkuTimeRangeCO {    /**     * 开始时间     */    @JSONField(name = "st")    private long effectSt;
/** * 结束时间 */ @JSONField(name = "ed") private long effectEd;}
复制代码


刚开始数据量不大,大 key 不明显,运营 N 年后,数据量变成 1000+条,大 key 就体现出来了


改造后 key 变成 promise:control:${controlType}:#{deliveryCenterId}:#{storeId),如上图。

3)热 key 治理

热 key 产生有很多原因


案例 1: 流量倾斜:比如流量严重倾斜导致的,比如大促扩容机器,都是 copy 行云分组,导致新机器都链接到同一个 config 对应的 S 分片,如果 S 分片是默认读组,则新机器流量都打到这个分片上,流量高峰期则会产生热 key。 解决方案:分组修改 confing,让 jimdb 负载均衡平均。或者修改读组策略(比如轮循 s 分片)


案例 2: 比如 promise:xxx:yyy|:zzz 这个 key,固定前置,hash 到固定的某个槽位,流量都打到这个机器。即是热 key 也是大 key


解决方案: 1)首先本地缓存是一方面,但没有从根源解决。 2)如果某个关键被确认为热点,一个简单方法在关键字开头或者结尾添加一个随机数(比如两位数),这样就可以将关键字分布到 100 个不同的关键字上,从而分配到不同的分区上。比如上面 key 对应改造为 promise:xxx:yyy|:zzz:01 ...... promise:xxx:yyy|:zzz:50 ...... promise:xxx:yyy|:zzz:99

参考文献

1、《数据密集型应用系统设计》


2、 《Redis 设计与实现 第二版》


3、《Redis5 设计与源码分析》


4、Redis之哈希分片原理一致性哈希算法与crc16算法


5、一致性 Hash 算法原理&应用梳理


如果文中有任何不足之处,恳请各位不吝赐教,留言指正。谢谢大家的阅读和反馈!

发布于: 刚刚阅读数: 3
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
「数据密集型应用系统设计」读后感与团队高并发高性能实践案例_京东科技开发者_InfoQ写作社区