作者:京东物流 冯志文
一、分布式数据系统挑战
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日期,执行dele
allFields.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|01 ...... promise:xxx:yyy|50 ...... promise:xxx:yyy|99
参考文献
1、《数据密集型应用系统设计》
2、 《Redis设计与实现 第二版》
3、《Redis5设计与源码分析》
4、Redis之哈希分片原理一致性哈希算法与crc16算法
如果文中有任何不足之处,恳请各位不吝赐教,留言指正。谢谢大家的阅读和反馈!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。