高并发项目部署以及优化手段

文章目录

🔊博主介绍

📕我是廖志伟,一名Java开发工程师、Java领域优质创作者、CSDN博客专家、51CTO专家博主、阿里云专家博主、清华大学出版社签约作者、产品软文创造者、技术文章评审老师、问卷调查设计师、个人社区创始人、开源项目贡献者。🌎跑过十五公里、徒步爬过衡山、🔥有过三个月减肥20斤的经历、是个喜欢躺平的狠人。

🤖博主的简介

📘拥有多年一线研发和团队管理经验,研究过主流框架的底层源码(Spring、SpringBoot、Spring MVC、SpringCould、Mybatis、Dubbo、Zookeeper),消息中间件底层架构原理(RabbitMQ、RockerMQ、Kafka)、Redis缓存、MySQL关系型数据库、 ElasticSearch全文搜索、MongoDB非关系型数据库、Apache ShardingSphere分库分表读写分离、设计模式、领域驱动DDD、Kubernetes容器编排等。🎥有从0到1的高并发项目经验,利用弹性伸缩、负载均衡、报警任务、自启动脚本,最高压测过200台机器,有着丰富的项目调优经验。

以梦为马,不负韶华

📙经过多年在CSDN创作上千篇文章的经验积累,我已经拥有了不错的写作技巧。同时,我还与清华大学出版社签下了四本书籍的合约,并将陆续在明年出版。这些书籍包括了基础篇、进阶篇、架构篇的📌《Java项目实战—深入理解大型互联网企业通用技术》📌,以及📚《解密程序员的思维密码–沟通、演讲、思考的实践》📚。具体出版计划会根据实际情况进行调整,希望各位读者朋友能够多多支持!

📥博主的目标

探寻内心世界,博主分享人生感悟与未来目标

  • 🍋程序开发这条路不能停,停下来容易被淘汰掉,吃不了自律的苦,就要受平庸的罪,持续的能力才能带来持续的自信。我本是一个很普通的程序员,放在人堆里,除了与生俱来的盛世美颜,就剩180的大高个了,就是我这样的一个人,默默写博文也有好多年了。
  • 📺有句老话说的好,牛逼之前都是傻逼式的坚持,希望自己可以通过大量的作品、时间的积累、个人魅力、运气、时机,可以打造属于自己的技术影响力。
  • 💥内心起伏不定,我时而激动,时而沉思。我希望自己能成为一个综合性人才,具备技术、业务和管理方面的精湛技能。我想成为产品架构路线的总设计师,团队的指挥者,技术团队的中流砥柱,企业战略和资本规划的实战专家。
  • 🎉这个目标的实现需要不懈的努力和持续的成长,但我必须努力追求。因为我知道,只有成为这样的人才,我才能在职业生涯中不断前进并为企业的发展带来真正的价值。在这个不断变化的时代,我们必须随时准备好迎接挑战,不断学习和探索新的领域,才能不断地向前推进。我坚信,只要我不断努力,我一定会达到自己的目标。

CSDN

文章目录

🌾阅读前,快速浏览目录和章节概览可帮助了解文章结构、内容和作者的重点。了解自己希望从中获得什么样的知识或经验是非常重要的。建议在阅读时做笔记、思考问题、自我提问,以加深理解和吸收知识。

💡在这个美好的时刻,本人不再啰嗦废话,现在毫不拖延地进入文章所要讨论的主题。接下来,我将为大家呈现正文内容。

🥤本文内容


高并发项目上百种优化手段

🌟 服务器配置

🍊 文件系统参数、TCP网络层参数等系统参数

在CentOS 7上,可以使用以下命令来配置参数:

  1. 打开配置文件/etc/sysctl.conf:
sudo vi /etc/sysctl.conf
  1. 添加或编辑以下参数:
# 设置虚拟内存超配值为 1,可以允许分配比实际物理内存更多的内存空间,从而提高应用程序性能,但可能导致 OOM 错误。为 0 时表示内存空间不足时直接拒绝申请
vm.overcommit_memory = 1
# 设置系统脏页(未写入磁盘的页面)达到多少字节时可以开始写入磁盘
vm.dirty_background_bytes = 8388608
# 设置系统脏页达到多少字节时必须写入磁盘
vm.dirty_bytes = 25165824
# 设置可以在后台写入磁盘的脏页占总脏页的比例(即总脏页数的2%)
vm.dirty_background_ratio = 2
# 设置当系统脏页占总内存的比例超过5%时,系统必须开始写入磁盘
vm.dirty_ratio = 5
# 设置系统判定一个脏页需要写入磁盘的时间,单位为centisecond,即2000分之一秒
vm.dirty_expire_centisecs = 2000
# 设置最小内存分配单位(单位为KB)
vm.min_free_kbytes = 8192
# 将虚拟内存的 overcommit 比率设置为80%
vm.overcommit_ratio = 80
# 设置堆内存溢出处理方式(0表示不紧急内存压缩,1表示紧急内存压缩,2表示杀死进程)
vm.panic_on_oom = 2
# 设置发送方socket buffer大小的最大值为16MB
net.core.wmem_max = 16777216
# 设置接收方socket buffer大小的最大值为16MB
net.core.rmem_max = 16777216
# 当TCP流量控制窗口溢出时,中止连接
net.ipv4.tcp_abort_on_overflow = 1
# 对于高延迟、高带宽的网络,开启窗口缩放
net.ipv4.tcp_adv_win_scale = 1
# 允许使用的TCP拥塞控制算法,可以使用cubic和reno算法
net.ipv4.tcp_allowed_congestion_control = cubic reno
# 应用程序socket buffer的大小,单位为Kbyte
net.ipv4.tcp_app_win = 31
# TCP发送数据时,自动开启corking模式
net.ipv4.tcp_autocorking = 1
# 允许使用的TCP拥塞控制算法,可以使用cubic和reno算法
net.ipv4.tcp_available_congestion_control = cubic reno
# 设置TCP数据包的最小大小,单位为byte
net.ipv4.tcp_base_mss = 512
# 发送方最多允许发送多少个SYN报文段作为challenge ack防范syn flood攻击
net.ipv4.tcp_challenge_ack_limit = 1000
# TCP使用的拥塞控制算法,可以使用cubic算法
net.ipv4.tcp_congestion_control = cubic
# 开启对方乱序数据的确认,以降低网络延迟
net.ipv4.tcp_dsack = 1
# 当检测到丢包时,提前触发重传
net.ipv4.tcp_early_retrans = 3
# 开启ECNExplicit Congestion Notification)拥塞控制算法
net.ipv4.tcp_ecn = 2
# 使用FACKForward Acknowledgment)作为拥塞控制算法的一部分
net.ipv4.tcp_fack = 1
# 开启TCP Fast Open,以加快连接速度
net.ipv4.tcp_fastopen = 3
# 设置TCP Fast Open使用的密钥,可以使用随机数生成器生成
net.ipv4.tcp_fastopen_key = 6d0c41a3-123fdf85-a7f901e8-59fea180
# TCP连接关闭的超时时间,单位为秒
net.ipv4.tcp_fin_timeout = 10
# 开启TCP Fast Recovery防止网络拥塞
net.ipv4.tcp_frto = 2
# 设置TCP连接每秒允许的最大无效数据包数,超过该值则降低发送速度
net.ipv4.tcp_invalid_ratelimit = 500
# TCP保持连接的时间间隔,单位为秒
net.ipv4.tcp_keepalive_intvl = 15
# 发送TCP保持连接探测报文的次数
net.ipv4.tcp_keepalive_probes = 3
# TCP保持连接的时间,单位为秒
net.ipv4.tcp_keepalive_time = 600
# 限制发送缓存的最大空间,单位为byte
net.ipv4.tcp_limit_output_bytes = 262144
# 开启TCP低延迟模式
net.ipv4.tcp_low_latency = 0
# 操作系统允许的最大TCP半连接数
net.ipv4.tcp_max_orphans = 16384
# TCP拥塞窗口增长算法的阈值,一般设为0不使用该功能
net.ipv4.tcp_max_ssthresh = 0
# 等待建立连接请求的最大个数
net.ipv4.tcp_max_syn_backlog = 262144
# 每秒最多处理的TCP连接数,越高则占用CPU时间越多
net.ipv4.tcp_max_tw_buckets = 5000
# 设置TCP Mem,包括min、default、max三个参数,单位为page数量
net.ipv4.tcp_mem = 88053	117407	176106
# 设置发送方socket buffer大小的最小值,单位为byte
net.ipv4.tcp_min_snd_mss = 48
# 设置TCP使用的最小TSO分段数目(只有在开启TSO时生效)
net.ipv4.tcp_min_tso_segs = 2
# 开启TCP自适应窗口大小控制
net.ipv4.tcp_moderate_rcvbuf = 1
# 开启TCP MTU探测,以避免网络分片
net.ipv4.tcp_mtu_probing = 1
# 禁止保存TCP延迟测量得到的数据
net.ipv4.tcp_no_metrics_save = 1
# 无需等待发送缓存为空,就可以发送数据
net.ipv4.tcp_notsent_lowat = -1
# TCP重传数据包的最大次数
net.ipv4.tcp_orphan_retries = 0
# TCP重传数据包后允许接收的最大乱序数据包个数
net.ipv4.tcp_reordering = 3
# 启用TCP Fast RetransmitFast Recovery算法
net.ipv4.tcp_retrans_collapse = 1
# 第一次重传TCP数据包的次数
net.ipv4.tcp_retries1 = 3
# 第二次重传TCP数据包的次数
net.ipv4.tcp_retries2 = 15
# 拒绝与RFC1337不兼容的数据包
net.ipv4.tcp_rfc1337 = 1
# 设置TCP接收缓存大小,包括min、default、max三个参数,单位为byte
net.ipv4.tcp_rmem = 4096	87380	33554432
# 开启TCP SACKSelective Acknowledgments)支持
net.ipv4.tcp_sack = 1
# 关闭TCP连接空闲一段时间后再次发送数据包
net.ipv4.tcp_slow_start_after_idle = 0
# 禁用TCP Socket Urgent功能
net.ipv4.tcp_stdurg = 0
# TCP SYN请求重试的最大次数
net.ipv4.tcp_syn_retries = 1
# TCP SYN/ACK请求重试的最大次数
net.ipv4.tcp_synack_retries = 1
# 开启TCP SYN Cookie防止syn flood攻击
net.ipv4.tcp_syncookies = 1
# 关闭TCP Thin Dupack
net.ipv4.tcp_thin_dupack = 0
# 关闭TCP Thin Linear Timeouts
net.ipv4.tcp_thin_linear_timeouts = 0
# 开启TCP时间戳
net.ipv4.tcp_timestamps = 1
# 设置TCP TSO窗口大小的除数,只有在开启TSO时生效
net.ipv4.tcp_tso_win_divisor = 3
# 开启TCP TIME_WAIT Socket重用机制
net.ipv4.tcp_tw_recycle = 1
# 允许将TIME_WAIT Socket重用于新的TCP连接
net.ipv4.tcp_tw_reuse = 1
# 开启TCP窗口缩放
net.ipv4.tcp_window_scaling = 1
# 设置发送方socket buffer大小,包括min、default、max三个参数,单位为byte
net.ipv4.tcp_wmem = 4096	16384	33554432
# 关闭TCP workaround signed windows(https://tools.ietf.org/html/rfc7323)
net.ipv4.tcp_workaround_signed_windows = 0
# 当使用conntrack跟踪TCP连接时,设置是否采用liberal模式
net.netfilter.nf_conntrack_tcp_be_liberal = 0
# 当使用conntrack跟踪TCP连接时,设置是否采用loose模式
net.netfilter.nf_conntrack_tcp_loose = 1
# TCP连接最大重传次数
net.netfilter.nf_conntrack_tcp_max_retrans = 3
# TCP连接关闭后,等待fin结束的时间,单位为秒
net.netfilter.nf_conntrack_tcp_timeout_close = 10
# TCP连接关闭后,进入CLOSE_WAIT状态的时间,单位为秒
net.netfilter.nf_conntrack_tcp_timeout_close_wait = 60
# TCP连接已经建立时,如果长期没有数据传输,连接最长保持时间,单位为秒
net.netfilter.nf_conntrack_tcp_timeout_established = 432000
# 当关闭TCP连接时,TCP_FIN等待ACK的超时时间,单位为秒
net.netfilter.nf_conntrack_tcp_timeout_fin_wait = 120
# 当关闭TCP连接时,ACK等待FIN的超时时间,单位为秒
net.netfilter.nf_conntrack_tcp_timeout_last_ack = 30
# TCP连接最大重传次数,以及TCP RTO
net.netfilter.nf_conntrack_tcp_timeout_max_retrans = 300
# 设置 TCP SYN_RECV 状态的超时时间为 60 秒
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 60
# 设置 TCP SYN_SENT 状态的超时时间为 120 秒
net.netfilter.nf_conntrack_tcp_timeout_syn_sent = 120
# 设置 TCP TIME_WAIT 状态的超时时间为 120 秒
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120
# 设置 TCP 未确认连接的超时时间为 300 秒
net.netfilter.nf_conntrack_tcp_timeout_unacknowledged = 300
# 设置 sunrpc 协议的 FIN 超时时间为 15 秒
sunrpc.tcp_fin_timeout = 15
# 设置 sunrpc 协议的最大槽位表项数为 65536
sunrpc.tcp_max_slot_table_entries = 65536
# 设置 sunrpc 协议的槽位表项数为 2
sunrpc.tcp_slot_table_entries = 2
# 设置 sunrpc 协议的传输层为 TCP,缓存区大小为 1048576 字节
sunrpc.transports = tcp 1048576
# 设置系统最大连接数为 65535
net.core.somaxconn = 65535
# 设置网络设备缓存队列最大值为 65535
net.core.netdev_max_backlog = 65535
# 设置系统的最大文件句柄数为 65535
fs.file-max = 65535
# 增加文件描述符限制
fs.nr_open = 1000000
# 设置同时为当前用户打开的 inotify 实例的最大数目为 1024
fs.inotify.max_user_instances = 1024
# 设置当前用户为每个 inotify 实例可同时监视的文件和目录数目上限为 65536
fs.inotify.max_user_watches = 65536
# 设置 inotify 实例中等待处理的事件队列的最大(未处理)长度为 16384
fs.inotify.max_queued_events = 16384
# 调整文件系统缓存参数
vfs_cache_pressure = 50
# 设置进程ID的最大值为131072
kernel.pid_max = 131072
# 设置系统支持的最大进程ID值为131072
kernel.max_pid = 131072
# 设置系统的信号量的参数,分别是512个信号量集、每个信号量集的最大值为65535、每个进程最多可以持有的信号量数量为1024、最大的信号量值为2048
kernel.sem = 512 65535 1024 2048
  1. 保存文件并退出。

  2. 使用以下命令使新配置生效:

sudo sysctl -p
  1. 使用命令行工具查看TCP网络参数的值,例如使用命令:
sysctl -a | grep tcp

可以查看到当前TCP网络参数的值,确认修改是否生效。
在这里插入图片描述

🍊 修改文件描述符大小

  1. 打开命令行终端,输入以下命令:
sudo vi /etc/security/limits.conf
  1. 在打开的配置文件中添加以下代码:
*    soft    nofile    65535
*    hard    nofile    65535
  1. 保存文件并关闭。

  2. 输入以下命令,使配置修改生效:

ulimit -n 65535

注意:此配置只对当前用户有效,如果要对所有用户生效,需要重启系统。

以上配置仅供参考,具体的参数设置应根据实际情况进行调整。在更改任何系统参数之前,请确保了解所需的配置和可能的影响。

🌟 SpringBoot的配置

Tomcat是Spring Boot默认的Web容器,它的配置也需要进行优化,以提高性能。

🍊 1. 配置HTTP协议下的数据压缩

Spring Boot使用内嵌的Tomcat作为其默认的Web容器,支持HTTP协议下的数据压缩。

要启用数据压缩,需要在application.properties文件中添加以下配置:

server.compression.enabled=true
server.compression.mime-types=application/json,application/xml,text/html,text/xml,text/plain
server.compression.min-response-size=2048

解释一下每一个配置项的含义:

  • server.compression.enabled:启用数据压缩,默认为false。
  • server.compression.mime-types:需要压缩的数据类型列表,支持多个值,用逗号分隔。
  • server.compression.min-response-size:响应数据的最小大小(字节),只有响应数据大小超过该值才会进行压缩。

配置完成后,当客户端请求的Accept-Encoding头中包含“gzip”或“deflate”时,Tomcat会自动压缩响应数据并返回。

需要注意的是,如果使用了反向代理服务器(如Nginx),则需要确保代理服务器不会重复压缩响应数据,否则可能会导致网页无法正确加载。可以通过设置代理服务器的“proxy_set_header Accept-Encoding ”和“proxy_set_header TE ”选项来解决该问题。

🍊 2. 配置静态资源缓存

Spring Boot的默认配置会自动将静态资源缓存一段时间,并指定缓存路径。默认情况下,静态资源的缓存时间是1小时(3600秒),缓存路径为“/static/”和“/public/” 。

如果需要自定义静态资源的缓存配置,可以在application.properties中添加如下配置:

# 配置静态资源缓存时间为10分钟
spring.resources.cache.cachecontrol.max-age=600

# 配置缓存路径
spring.resources.static-locations=classpath:/static/,classpath:/public/

在上述配置中,通过spring.resources.cache.cachecontrol.max-age可以配置缓存时间,单位为秒;通过spring.resources.static-locations可以配置缓存路径。在配置路径时,需要指定静态资源存放的位置,多个位置可以使用逗号分隔。

需要注意的是,如果静态资源名带有版本号或者时间戳等动态变化的参数,那么缓存路径需要指定到该参数前面的部分,否则可能会导致缓存无效。

🍊 3.Tomcat线程池配置

在高并发场景下,线程池的配置也是非常重要的,可以大大提高系统的并发处理能力。

Spring Boot默认使用Tomcat线程池,它提供了以下参数可以进行调整:

(1)最大工作线程数(maxThreads):表示Tomcat可以处理的并发请求数量,超过最大线程数的请求将会被拒绝。可以根据预期并发请求量进行调整,建议设置为CPU核心数量的2~4倍。

(2)最小工作线程数(minSpareThreads):表示Tomcat最少保持的空闲工作线程。可以根据预期并发请求量进行调整,建议设置为CPU核心数量。

(3)最大连接数(maxConnections):表示Tomcat可以处理的最大请求数量,超过最大连接数的请求将会被放入等待队列。可以根据预期并发请求量进行调整,建议设置为maxThreads的2~4倍。

(4)等待队列长度(acceptCount):表示Tomcat等待队列的长度,超过等待队列长度的请求将会被拒绝。可以根据预期并发请求量进行调整,建议设置为maxConnections的2~4倍。

假设某场景2万用户30秒内处理每个用户5次请求,那么QPS为20000*5/30=3333.3,那么对应的配置如下:

# 🌟  server配置
server:
  compression:
    # 启用数据压缩,默认为false。配置完成后,当客户端请求的Accept-Encoding头中包含“gzip”或“deflate”时,Tomcat会自动压缩响应数据并返回。
    enabled: true
    # 需要压缩的数据类型列表,支持多个值,用逗号分隔。
    mime-types: application/json,application/xml,text/html,text/xml,text/plain
    # 响应数据的最小大小(字节),只有响应数据大小超过该值才会进行压缩。
    min-response-size: 2048
  # 服务端口
  port: 8097
  tomcat:
    # 1核2g内存为200,线程数经验值200左右;4核8g内存,线程数经验值800左右,16核32G内存,线程数经验值3200左右,以此类推,对应的服务器配置也需要跟着调整。这里有二种方式:第一种直接上对应的高配服务器,第二种搭建集群,通过弹性伸缩(需要配置报警任务触发扩容)+负载均衡(需要监听服务端口)+云服务器。
    threads:
      # 最多的工作线程数,默认大小是200。该参数相当于临时工,如果并发请求的数量在10到200之间,就会使用这些临时工线程进行处理。建议设置为 2 倍到 4 倍的 QPS
      max: 6667
      # 最少的工作线程数,默认大小是10。该参数相当于长期工,如果并发请求的数量达不到10,就会依次使用这几个线程去处理请求。如果min-spare设置得太低,那么当应用程序接收到高并发请求时,线程池将无法满足服务要求而导致请求失败。较高的min-spare值可能会导致系统响应时间变慢,因为它会创建大量线程来处理请求,这可能会占用过多的CPU和内存资源。如果将min-spare值设置得太低,则线程池可能无法及时响应请求。当系统负载较高时,有些请求可能会被暂时挂起,等待线程变得可用。如果没有足够的空闲线程,则请求将会等待更长时间。设置较高的min-spare值会占用更多的内存资源。如果线程池中的线程数超出了系统的实际需求,则会浪费内存资源。因此,将min-spare值设置为10至20是一种平衡内存和线程利用率的方式。
      min-spare: 20
      # 最大连接数,默认大小是8192。表示Tomcat可以处理的最大请求数量,超过8192的请求就会被放入到等待队列。如果设置为-1,则禁用maxconnections功能,表示不限制tomcat容器的连接数。建议设置为 2 倍到 4 倍的 QPS。如果设置的值太低,将会限制服务器处理客户端请求的能力,从而可能导致应用程序出现性能问题。如果设置的值太高,则会浪费服务器资源,因为服务器的处理能力可能不足以处理所有的连接。通过经验和测试,2到4倍的QPS值通常会在服务器处理客户端请求时提供最佳性能和稳定性。这个范围也会提供一定的缓冲以应对突发流量,从而在服务器资源短缺时避免过载。
    max-connections: 6667
    # 等待队列的长度,默认大小是100。建议设置为 2 至 5 倍的 max-connections。将accept-count设置为2至5倍的max-connections可以确保Tomcat能够处理足够的连接请求,同时避免因过多排队连接导致的性能问题。但是,设置过高的accept-count会增加系统负担和内存压力,同时也可能会引起其他问题,如拒绝服务攻击等。至于为什么建议不超过5倍,是因为实际上超过这个范围的设置已经很少能带来明显的性能提升,反而会增加系统负担。同时,设置过高的accept-count还可能会导致频繁的连接请求失败和性能下降,甚至可能会导致Tomcat崩溃。
    accept-count: 13334

🌟 JVM调优

🍊 目的和原则

JVM调优的主要目的是减少GC的频率和Full GC的次数,并降低STW的停顿时间和次数。

首先,尽可能让对象都在新生代里分配和回收。由于新生代的垃圾回收速度比老年代要快得多,因此将对象尽量分配到新生代中可以减少老年代的负担,降低GC的频率和Full GC的次数。为避免大量对象进入老年代,可以设置适当的新生代大小和比例,以确保不会频繁地进行老年代的垃圾回收。

其次,给系统充足的内存大小。为避免频繁的垃圾回收和Full GC,可以适当增加系统的内存大小。此外,还可以设置合理的堆空间大小,使得堆空间不会快速被占满。这样可以减少GC的频率和Full GC的次数,降低STW的停顿时间和次数,提高系统的稳定性和性能。

最后,避免频繁进行老年代的垃圾回收。老年代的垃圾回收通常是比较耗时的,因此应该尽量避免频繁进行老年代的垃圾回收。可以通过合理设置新生代大小、年龄等参数以及使用CMS等垃圾回收器来减少对老年代的垃圾回收,从而降低GC的频率和Full GC的次数,提高系统的性能和稳定性。

🍊 可能导致Full GC

以下是可能引起内存泄漏并导致Full GC的一些情况:
(1)对象的长期存活:如果某些对象在JVM中存活了很长时间,可能会导致内存泄漏,并在堆积积累的过程中触发Full GC。
(2)大对象:如果程序中创建了大的对象,但这些对象无法被回收,可能会导致内存泄漏,并触发Full GC。
(3)永久代内存溢出:当应用程序使用大量字符串或其他可序列化的类时,可能会导致永久代内存耗尽,并触发Full GC。
(4)字符串:如果在程序中频繁创建字符串,并且它们不被清除,可能会导致内存泄漏,并触发Full GC。
(5)无用的类和对象:如果程序中存在许多无用的类和对象,可能会导致内存泄漏,并触发Full GC。
(6)ThreadLocal:如果程序中使用了ThreadLocal,但没有正确地清除线程本地存储,可能会导致内存泄漏,并触发Full GC。
(7)频繁创建对象:如果程序中频繁地创建对象,但没有正确地清除这些对象,可能会导致内存泄漏,并触发Full GC。

首先,在使用System.gc()方法时,尽量避免使用该方法,因为调用该方法会建议JVM进行Full GC,而可能会增加Full GC的频率,从而增加间歇性停顿的次数。为了减少该方法的使用,可以禁止RMI调用System.gc(),通过-XX:+DisableExplicitGC参数来实现。

其次,如果Survivor区域的对象满足晋升到老年代的条件,但是晋升进入老年代的对象大小大于老年代的可用内存时,就会触发Full GC。为了避免这种情况,可以通过调整JVM参数或者设计应用程序的算法,来减少对象在老年代的数量。

JDK8开始,Metaspace区取代了永久代(PermGen),Metaspace使用的是本地内存。通过调整JVM参数限制Metaspace的大小,当Metaspace区内存达到阈值时,也会引发Full GC。可以通过调整JVM参数或者设计应用程序的算法,来减少Metaspace区内存的使用。
在Survivor区域的对象满足晋升到老年代的条件时,也可能会引起Full GC。可以通过调整JVM参数来控制对象的晋升行为,从而减少Full GC的发生。

此外,如果堆中产生的大对象超过阈值,也会引发Full GC。可以通过调整JVM参数或者优化应用程序算法,来减少大对象的产生。

最后,老年代连续空间不足或者CMS GC时出现promotion failed和concurrent mode failure所致的Full GC,也可以通过调整JVM参数或者设计应用程序算法来减少其发生。

🍊 场景调优

对于场景问题,可以从如下几个大方向进行设计:

  1. 大访问压力下,MGC 频繁一些是正常的,只要MGC 延迟不导致停顿时间过长或者引发FGC ,那可以适当的增大Eden 空间大小,降低频繁程度,同时要保证,空间增大对垃圾回收产生的停顿时间增长是可以接受的。

  2. 如果MinorGC 频繁,且容易引发 Full GC。需要从如下几个角度进行分析。

  • 每次MGC存活的对象的大小,是否能够全部移动到 S1区,如果S1 区大小 < MGC 存活的对象大小,这批对象会直接进入老年代。注意 了,这批对象的年龄才1岁,很有可能再多等1次MGC 就能被回收了,可是却进入了老年代,只能等到Full GC 进行回收,很可怕。这种情况下,应该在系统压测的情况下,实时监控MGC存活的对象大小,并合理调整eden和s 区的大小以及比例。
  • 还有一种情况会导致对象在未达到15岁之前,直接进入老年代,就是S1区的对象,相同年龄的对象所占总空间大小>s1区空间大小的一半,所以为了应对这种情况,对于S区的大小的调整就要考虑:尽量保证峰值状态下,S1区的对象所占空间能够在MGC的过程中,相同对象年龄所占空间不大于S1区空间的一半, 因此对于S1空间大小的调整,也是十分重要的。
  1. 由于大对象创建频繁,导致Full GC 频繁。对于大对象,JVM专门有参数进行控制,-XX: PretenureSizeThreshold。超过这个参数值的对象,会直接进入老年代,只能等到full GC 进行回收,所以在系统压测过程中,要重点监测大对象的产生。如果能够优化对象大小,则进行代码层面的优化,优化如:根据业务需求看是否可以将该大对象设置为单例模式下的对象,或者该大对象是否可以进行拆分使用,或者如果大对象确定使用完成后,将该对象赋值为null,方便垃圾回收。
    如果代码层面无法优化,则需要考虑:
  • 调高-XX: PretenureSizeThreshold参数的大小,使对象有机会在eden区创建,有机会经历MGC以被回收。但是这个参数的调整要结合MGC过程中Eden区的大小是否能够承载,包括S1区的大小承载问题。
  • 这是最不希望发生的情况, 如果必须要进入老年代,也要尽量保证,该对象确实是长时间使用的对象,放入老年代的总对象创建量不会造成老年代的内存空间迅速长满发生Full GC,在这种情况下,可以通过定时脚本,在业务系统不繁忙情况下,主动触发full gc。
  1. MGC 与 FGC 停顿时间长导致影响用户体验。其实对于停顿时间长的问题无非就两种情况:
  • gc 真实回收过程时间长,即real time时间长。这种时间长大部分是因为内存过大导致,从标记到清理的过程中需要对很大的空间进行操作,导致停顿时间长。这种情况,要考虑减少堆内存大 小,包括新生代和老年代,比如之前使用16G的堆内存,可以考虑将16G 内存拆分为4个4G的内存区域,可以单台机器部署JVM逻辑集群,也可以为了降低GC回收时间,进行4节点的分布式部署,这里的分布式部署是为了降低 GC垃圾回收时间。
  • gc真实回收时间 real time 并不长,但是user time(用户态执行时间) 和 sys time(核心态执行时间)时间长,导致从客户角度来看,停顿时间过长。这种情况,要考虑线程是否及时达到了安全点,通过-XX:+PrintSafepointStatistics-XX: PrintSafepointStatisticsCount=1去查看安全点日志,如果有长时间未达到安全点的线程,再通过参数-XX: +SafepointTimeout-XX:SafepointTimeoutDelay=2000两个参数来找到大于2000ms到达安全点的线程,这里 的2000ms可以根据情况自己设置,然后对代码进行针对的调整。除了安全点问题,也有可能是操作系统本身负载比较高,导致处理速度过慢,线程达到安全点时间长,因此需要同时检测操作系统自身的运行情况。
  1. 内存泄漏导致的MGC和FGC频繁,最终引发oom。纯代码级别导致的MGC和FGC频繁。如果是这种情况,那就只能对代码进行大范围的调整,这种情况就非常多了,而且会很糟糕。如大循环体中的new 对象,未使用合理容器进行对象托管导致对象创建频繁,不合理的数据结构使用等等。 总之,JVM的调优无非就一个目的,在系统可接受的情况下达到一个合理的MGC和FGC的频率以及可接受的回收时间。

🌟 MySQL调优

🍊 表结构设计

在进行数据库设计时,开发者需要关注表的规划。

首先,开发者要了解MySQL数据库的页大小。当表中的单行数据达到16KB时,这意味着表中只能存储一条数据,这对于数据库来说是不合理的。MySQL数据库将数据从磁盘读取到内存,它使用磁盘块作为基本单位进行读取。如果一个数据块中的数据一次性被读取,那么查询效率将会提高。

以InnoDB存储引擎为例,它使用页作为数据读取单位。页是磁盘管理的最小单位,默认大小为16KB。由于系统的磁盘块存储空间通常没有这么大,InnoDB在申请磁盘空间时会使用多个地址连续的磁盘块来达到页的大小16KB。

查询数据时,一个页中的每条数据都能帮助定位到数据记录的位置,从而减少磁盘I/O操作,提高查询效率。InnoDB存储引擎在设计时会将根节点常驻内存,尽力使树的深度不超过3。这意味着在查询过程中,I/O操作不超过3次。树形结构的数据可以让系统高效地找到数据所在的磁盘块。

在这里讨论一下B树和B+树的区别。B树的结构是每个节点既包含key值也包含value值,而每个页的存储空间是16KB。如果数据较大,将会导致一个页能存储数据量的数量很小。相比之下,B+树的结构是将所有数据记录节点按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储key值信息。这样可以大大加大每个节点存储的key值数量,降低B+树的高度。

通过了解MySQL数据库底层存储的原理和数据结构,开发者在设计表时应该尽量减少单行数据的大小,将字段宽度设置得尽可能小。

在设计表时,开发者要注意以下几点以提高查询速度和存储空间利用率:

(1)避免使用text、Blob、Clob等大数据类型,它们占用的存储空间更大,读取速度较慢。

(2)尽量使用数字型字段,如性别字段用0/1的方式表示,而不是男女。这样可以控制数据量,增加同一高度下B+树容纳的数据量,提高检索速度。

(3)使用varchar/nvarchar代替char/nchar。变长字段存储空间较小,可以节省存储空间。

(4)不在数据库中存储图片、文件等大数据,可以通过第三方云存储服务存储,并提供图片或文件地址。

(5)金额字段使用decimal类型,注意长度和精度。如果存储的数据范围超过decimal的范围,建议将数据拆成整数和小数分开存储。

(6)避免给数据库留null值。尤其是时间、整数等类型,可以在建表时就设置非空约束。NULL列会使用更多的存储空间,在MySQL中处理NULL值也更复杂。为NULL的列可能导致固定大小的索引变成可变大小的索引,例如只有整数列的索引。

🍊 建索引

在建立索引时,需要权衡数据的维护速度和查询性能。以下是一些关于如何确定是否为表中字段建立索引的示例:

(1)对于经常修改的数据,建立索引会降低数据维护速度,因此不适合对这些字段建立索引,例如状态字段。

(2)对于性别字段,通常用0和1表示,但由于其区分度不高(100万用户中90万为男性,10万为女性),因此一般不需要建立索引。然而,如果性别字段的区分度非常高(例如90万男性和10万女性),而且该字段不经常更改,则可以考虑为该字段建立索引。

(3)可以在where及order by涉及的列上建立索引。

(4)对于需要查询排序、分组和联合操作的字段,适合建立索引,以提高查询性能。

(5)索引并非越多越好,一个表的索引数最好不要超过6个。当为多个字段创建索引时,表的更新速度会减慢,因此应选择具有较高区分度且不经常更改的字段创建索引。

(6)尽量让字段顺序与索引顺序一致,复合索引中的第一个字段作为条件时才会使用该索引。

(7)遵循最左前缀原则:尽量确保查询中的索引列按照最左侧的列进行匹配。

🍊 SQL优化

为了优化SQL语句,需要了解数据库的架构、索引、查询优化器以及各种SQL执行引擎的机制等技术知识。

🎉 SQL编写

在编写SQL语句时,开发者需要注意一些关键点以提高查询性能。以下是一些建议:

(1)避免在WHERE子句中对查询的列执行范围查询(如NULL值判断、!=、<>、or作为连接条件、IN、NOT IN、LIKE模糊查询、BETWEEN)和使用“=”操作符左侧进行函数操作、算术运算或表达式运算,因为这可能导致索引失效,从而导致全表扫描。

(2)对于JOIN操作,如果数据量较大,先分页再JOIN可以避免大量逻辑读,从而提高性能。

(3)使用COUNT()可能导致全表扫描,如有WHERE条件的SQL,WHERE条件字段未创建索引会进行全表扫描。COUNT()只统计总行数,聚簇索引的叶子节点存储整行记录,非聚簇索引的叶子节点存储行记录主键值。非聚簇索引比聚簇索引小,选择最小的非聚簇索引扫表更高效。

(4)当数据量较大时,查询只返回必要的列和行,LIMIT 分页限制返回的数据,减少请求的数据量,插入建议分批次批量插入,以提高性能。

(5)对于大连接的查询SQL,由于数据量较多、又是多表,容易出现整个事务日志较大,消耗大量资源,从而导致一些小查询阻塞,所以优化方向是将它拆分成单表查询,在应用程序中关联结果,这样更利于高性能可伸缩,同时由于是单表减少了锁竞争效率上也有一定提升。

(6)尽量明确只查询所需列,避免使用SELECT *。SELECT *会导致全表扫描,降低性能。若必须使用SELECT *,可以考虑使用MySQL 5.6及以上版本,因为这些版本提供了离散读优化(Discretized Read Optimization),将离散度高的列放在联合索引的前面,以提高性能。

索引下推(ICP,Index Condition Pushdown)优化:ICP优化将部分WHERE条件的过滤操作下推到存储引擎层,减少上层SQL层对记录的索取,从而提高性能。在某些查询场景下,ICP优化可以大大减少上层SQL层与存储引擎的交互,提高查询速度。

多范围读取(MRR,Multi-Range Read)优化:MRR优化将磁盘随机访问转化为顺序访问,提高查询性能。当查询辅助索引时,首先根据结果将查询得到的索引键值存放于缓存中。然后,根据主键对缓存中的数据进行排序,并按照排序顺序进行书签查找。
这种顺序查找减少了对缓冲池中页的离散加载次数,可以提高批量处理对键值查询操作的性能。

在编写SQL时,使用EXPLAIN语句观察索引是否失效是个好习惯。索引失效的原因有以下几点:

(1)如果查询条件中包含OR,即使其中部分条件带有索引,也无法使用。

(2)对于复合索引,如果不使用前列,后续列也无法使用。

(3)如果查询条件中的列类型是字符串,则在条件中将数据使用引号引用起来非常重要,否则索引可能失效。

(4)如果在查询条件中使用运算符(如+、-、*、/等)或函数(如substring、concat等),索引将无法使用。

(5)如果MySQL认为全表扫描比使用索引更快,则可能不使用索引。在数据较少的情况下尤其如此。

🎉 SQL优化工具

常用的SQL优化方法包括:业务层逻辑优化、SQL性能优化、索引优化。

业务层逻辑优化:开发者需要重新梳理业务逻辑,将大的业务逻辑拆分成小的逻辑块,并行处理。这样可以提高处理效率,降低数据库的访问压力。

SQL性能优化:除了编写优化的SQL语句、创建合适的索引之外,还可以使用缓存、批量操作减少数据库的访问次数,以提高查询效率。

索引优化:对于复杂的SQL语句,人工直接介入调节可能会增加工作量,且效果不一定好。开发者的索引优化经验参差不齐,因此需要使用索引优化工具,将优化过程工具化、标准化。最好是在提供SQL语句的同时,给出索引优化建议。

🎉 慢SQL优化

影响程度一般的慢查询通常在中小型企业因为项目赶进度等问题常被忽略,对于大厂基本由数据库管理员通过实时分析慢查询日志,对比历史慢查询,给出优化建议。

影响程度较大的慢查询通常会导致数据库负载过高,人工故障诊断,识别具体的慢查询SQL,及时调整,降低故障处理时长。
当前未被定义为慢查询的SQL可能随时间演化为慢查询,对于核心业务,可能引发故障,需分类接入:

(1)未上线准慢查询:需要通过发布前集成测试流水线,通常都是经验加上explain关键字识别慢查询,待解决缺陷后才能发布上线。

(2)已上线准慢查询:表数据量增加演变为慢查询,比较常见,通常会变成全表扫描,开发者可以增加慢查询配置参数log_queries_not_using_indexes记录至慢日志,实时跟进治理。

🍊 数据分区

在面对大量数据时,分区可以帮助提高查询性能。分区主要分为两类:表分区和分区表。

🎉 表分区

表分区是在创建表时定义的,需要在表建立的时候创建规则。如果要修改已有的有规则的表分区,只能新增,不能随意删除。表分区的局限性在于单个MySQL服务器支持1024个分区。

🎉 分区表

当表分区达到上限时,可以考虑垂直拆分和水平拆分。垂直拆分将单表变为多表,以增加每个分区承载的数据量。水平拆分则是将数据按照某种策略拆分为多个表。

垂直分区的优点是可以减少单个分区的数据量,从而提高查询性能。但缺点是需要考虑数据的关联性,并在SQL查询时进行反复测试以确保性能。

对于包含大文本和BLOB列的表,如果这些列不经常被访问,可以将它们划分到另一个分区,以保证数据相关性的同时提高查询速度。

🎉 水平分区

随着数据量的持续增长,需要考虑水平分区。水平分区有多种模式,例如:
(1)范围(Range)模式:允许DBA将数据划分为不同的范围。例如DBA可以将一个表按年份划分为三个分区,80年代的数据、90年代的数据以及2000年以后的数据。

(2)哈希(Hash)模式:允许DBA通过对表的一个或多个列的Hash Key进行计算,最后通过这个Hash码不同数值对应的数据区域进行分区。例如DBA可以建立一个根据主键进行分区的表。

(3)列表(List)模式:允许系统通过DBA定义列表的值所对应行数据进行分割。例如DBA建立了一个横跨三个分区的表,分别根据2021年、2022年和2023年的值对应数据。

(4)复合模式(Composite):允许将多个模式组合使用,如在初始化已经进行了Range范围分区的表上,可以对其中一个分区再进行Hash哈希分区。

🍊 灾备处理

在MySQL中,冷热备份可以帮助 开发者在不影响性能的情况下确保数据的安全性。

🎉 冷备份

当某些数据不再需要或不常访问时,可以考虑进行冷备份。冷备份是在数据库关闭时进行的数据备份,速度更快,安全性也相对更高。例如您可以将一个不再需要的月度报告数据备份到外部存储设备,以确保在需要时可以轻松访问这些数据。

🎉 热备份

对于需要实时更新的数据,可以考虑热备份。热备份是在应用程序运行时进行的数据备份,备份的是数据库中的SQL操作语句。例如您可以将用户的购物记录备份到一个在线存储服务中,以便在需要时可以查看这些数据。

🎉 冷备份与热备份的权衡

(1)冷备份速度更快,因为它不涉及应用程序的运行,但可能需要外部存储设备。

(2)热备份速度较慢,因为它涉及应用程序的运行和数据库操作的记录。

(3)冷备份更安全,因为它在数据库关闭时进行,不受应用程序影响。

(4)热备份安全性稍低,因为它在应用程序运行时进行,需要保持设备和网络环境的稳定性。

🎉 备份注意事项

(1)备份过程中要保持设备和网络环境稳定,避免因中断导致数据丢失。

(2)备份时需要仔细小心,确保备份数据的正确性,以防止恢复过程中出现问题。

(3)热备份操作要特别仔细,备份SQL操作语句时不能出错。

总之,通过对冷热数据进行备份,可以在不影响应用程序性能的情况下确保数据的安全性。在实际应用中,应根据数据的需求和业务场景选择合适的备份策略。

🍊 高可用

在生产环境中,MySQL的高可用性变得越来越重要,因为它是一个核心的数据存储和管理系统,任何错误或中断都可能导致严重的数据丢失和系统瘫痪。因此,建立高可用的MySQL环境是至关重要的。

🎉 MMM

用于监控和故障转移MySQL集群。它使用虚拟IP(VIP)机制实现集群的高可用。集群中,主节点通过一个虚拟IP地址提供数据读写服务,当出现故障时,VIP会从原主节点漂移到其他节点,由这些节点继续提供服务。双主故障切换(MMM)的主要缺点是故障转移过程过于简单粗暴,容易丢失事务,因此建议采用半同步复制以降低失败概率。

🎉 MHA

它是一种用于故障切换的工具,能在30秒内完成故障切换,并在切换过程中最大程度地保证数据一致性。高可用性与可伸缩性(MHA)主要监控主节点的状态,当检测到主节点故障时,它会提升具有最新数据的从节点成为新的主节点,并通过其他从节点获取额外信息来避免数据一致性方面的问题。MHA可以单独部署,分为Manager节点和Node节点,分别部署在单独的机器上和每台MySQL机器上。Node节点负责解析MySQL日志,而Manager节点负责探测Node节点并判断各节点的运行状况。当检测到主节点故障时,Manager节点会直接提升一个从节点为新主节点,并让其他从节点挂载到新主节点上,实现完全透明。为了降低数据丢失的风险,建议使用MHA架构。

🎉 MGR

它是MySQL官方在5.7.17版本中正式推出的一种组复制机制,主要用于解决异步复制和半同步复制中可能产生的数据不一致问题。组复制(MGR)由若干个节点组成一个复制组,事务提交后,必须经过超过半数节点的决议并通过后才能提交。引入组复制主要是为了解决传统异步复制和半同步复制可能出现的数据不一致问题。

组复制的主要优点是基本无延迟,延迟较异步复制小很多,且具有数据强一致性,可以保证事务不丢失。然而,它也存在一些局限性:

(1)仅支持InnoDB存储引擎。

(2)表必须具有主键。

(3)仅支持GTID模式,日志格式为row格式。

🍊 异常发现处理

在使用MySQL时,可能会遇到各种异常情况,例如连接错误、查询错误、数据删除错误等等。在处理这些异常情况时,开发人员需要了解异常的原因和处理方法,以便及时排除问题,保障系统的稳定性和可靠性。

🎉 数据库监控

及时将数据库异常通过短信、邮件、微信等形式通知给管理员,并且可以将数据库运行的实时指标统计分析图表显示出来,便于更好地对数据库进行规划和评估,目前市面上比较主流的数据库监控工具有Prometheus + Grafana + mysqld_exporter(比较受欢迎)、SolarWinds SQL Sentry、Database Performance Analyzer、OpenFalcon。

🎉 数据库日志

在MySQL中,有一些关键的日志可以用作异常发现并通过这些日志给出解决方案:

(1)重做日志(redo log):记录物理级别的页修改操作,例如页号123、偏移量456写入了“789”数据。可以通过“show global variables like ‘innodb_log%’;”命令查看。主要用于事务提交时保证事务的持久性和回滚。

(2)回滚日志(undo log):记录逻辑操作日志,例如添加一条记录时会记录一条相反的删除操作。可以通过“show variables like ‘innodb_undo%’;”命令查看。主要用于保证事务的原子性,在需要时回滚事务。

(3)变更日志/二进制日志(bin log):记录数据库执行的数据定义语句(DDL)和数据操作语句(DML)等操作。例如数据库意外挂机时,可以通过二进制日志文件查看用户执行的命令,并根据这些操作指令恢复数据库或将数据复制到其他数据库中。可以通过“show variables like ‘%log_bin%’;”命令查看。主要用于性能优化和复制数据。

(4)慢查询日志:记录响应时间超过指定阈值的SQL语句。主要用于性能优化。可以通过“show variables like ‘%slow_query_log%’;”命令查看。

(5)错误日志:记录MySQL服务启动、运行、停止时的诊断信息、错误信息和警告提示。主要用于排查MySQL服务出现异常的原因。可以通过“SHOW VARIABLES LIKE ‘log_err%’;”命令查看。

(6)通用查询日志:记录用户的所有操作,无论是所有的SQL语句还是调整MySQL参数或者启动和关闭MySQL都会记录。可以还原操作的场景。通过SHOW VARIABLES LIKE ‘%general%’;命令查看。

(7)中继日志(relay log):只存在主从数据库的从数据库上,用于主从同步,可以在xx-relaybin.index索引文件和-relaybin.0000x数据文件查看。

(8)数据定义语句日志(ddl.log):记录数据定义的SQL,比如ALTER TABLE。

(9)processlist日志:查看正在执行的sql语句。

(10) innodb status日志:查看事务、锁、缓冲池和日志文件,主要用于诊断数据库性能。

🎉 数据库巡检

巡检工作保障系统平稳有效运行,比如飞机起飞巡检保证起飞后能够正常工作。巡检工作主要由数据库管理员和后端开发工程师负责。

数据库管理员主要负责处理数据库基础功能/高可用/备份/中间件/报警组件、集群拓扑、核心参数等集群层面的隐患、服务器硬件层面隐患,对于磁盘可用空间预测等范围。

后端开发工程师主要负责库表设计缺陷、数据库使用不规范等引起的业务故障或性能问题的隐患,定期采集整型字段值有没有超过最大值,因为整型类型的字段保存的数值有上限。对于读写情况需要定期观察表大小,找出有问题的大表进行优化调整。

🎉 资源评估

测试人员进行压测,观察极限环境下数据库各项指标是否正常工作,运维工程师或者数据库管理员对数据容量进行评估,服务器资源需要提前规划,同时设置预警通知,超过阈值安排相关人员进行扩容,从而保证数据库稳定运行。

🍊 数据服务

数据服务的主要目的是帮助用户规划和迁移数据,备份和恢复数据库以及进行数据校验等功能,以确保用户的数据始终处于安全可靠的状态。

🎉 子表结构生成

一个表进行拆分,会根据业务实际情况进行拆解,例如用户表可以根据地区拆分tb_user可拆分成上海地区的用户表(tb_user_sh)、广州地区的用户表(tb_user_gz),那么全国有很多个城市,每个地方都需要创建一张子表并且维护它会比较费时费力,通常情况下,会开发3个接口做表结构同步:根据主表创建子表、主表字段同步到子表、主表索引同步子表。

下面对这3个接口提供思路以及关键代码。

主表创建子表,代码如下:

/**
* {
*     "tableName": "tb_user",
*     "labCodes": [
*         "sh",//上海
*         "gz"//广州
*     ]
* }
*/
public Boolean createTable(ConfigReq reqObject) {
	if (CollectionUtils.isEmpty(reqObject.getLabCodes())) {
		return false;
	}
	List<String> labCodes = reqObject.getLabCodes();
	for (String labCode: labCodes){
		//主表表名
		String tableName = reqObject.getTableName();
		//子表后表名
		String newTable = String.format("%s_%s", tableName, labCode);
		//校验子表是否存在
		Integer checkMatrix = configExtMapper.checkTable(newTable);
		if(checkMatrix == null || checkMatrix.intValue() < 0){
		//创建子表结构
		configExtMapper.createConfigTable(tableName, newTable);
		}
		}
	return true;
}

主表字段同步到子表,代码如下:

/**
* 主表字段同步到子表
* @param masterTable 主表
* @return
*/
private Boolean syncAlterTableColumn(String masterTable) {
	String table = masterTable + "%";
	//获取子表名
	List<String> tables = configExtMapper.getTableInfoList(table);
	if(CollectionUtils.isEmpty(tables)){
		return false;
	}
	//获取主表结构列信息
	List<ColumnInfo> masterColumns = configExtMapper.getColumnInfoList(masterTable);
	if (masterColumns.isEmpty()){
		return false;
	}
	String alterName = null;
	for (ColumnInfo column: masterColumns) {
		column.setAlterName(alterName);
		alterName = column.getColumnName();
	}
	for(String tableName : tables){
		if(StringUtils.equalsIgnoreCase(tableName, masterTable)){
			continue;
		}
		//获取子表结构列信息
		List<ColumnInfo> columns = configExtMapper.getColumnInfoList(tableName);
		if(CollectionUtils.isEmpty(columns)){
			continue;
		}
		for (ColumnInfo masterColumn : masterColumns) {
			ColumnInfo column = columns.stream().filter(c -> StringUtils.equalsIgnoreCase(c.getColumnName(),
			masterColumn.getColumnName())).findFirst().orElse(null);
			if (column == null){
				column = new ColumnInfo();
				column.setColumnName(masterColumn.getColumnName());//列名
				column.setAddColumn(true);//是否修改
			}
			if (column.hashCode() == masterColumn.hashCode()){
				continue;
			}
			column.setTableName(tableName);//表名
			column.setColumnDef(masterColumn.getColumnDef());//是否默认值
			column.setIsNull(masterColumn.getIsNull());//是否允许为空(NO:不能为空、YES:允许为空)
			column.setColumnType(masterColumn.getColumnType());//字段类型(如:varchar(512)、text、bigint(20)、datetime)
			column.setComment(masterColumn.getComment());//字段备注(如:备注)
			column.setAlterName(masterColumn.getAlterName());//修改的列名
			//创建子表字段
			configExtMapper.alterTableColumn(column);
		}
	}
	return true;
}

主表索引同步子表,代码如下:

/**
* 主表索引同步子表
* @param masterTableName 主表名
* @return
*/
private Boolean syncAlterConfigIndex(String masterTableName) {
	String table = masterTableName + "%";
	//获取子表名
	List<String> tableInfoList = configExtMapper.getTableInfoList(table);
	if (tableInfoList.isEmpty()){
		return false;
	}
	// 获取所有索引
	List<String> allIndexFromTableName = configExtMapper.getAllIndexNameFromTableName(masterTableName);
	if (CollectionUtils.isEmpty(allIndexFromTableName)) {
		return false;
	}
	for (String indexName : allIndexFromTableName) {
		//获取拥有索引的列名
		List<String> indexFromIndexName = configExtMapper.getAllIndexFromTableName(masterTableName, indexName);
		for (String tableName : tableInfoList) {
			if (!tableName.startsWith(masterTableName)) {
				continue;
			}
			//获取索引名称
			List<String> addIndex = configExtMapper.findIndexFromTableName(tableName, indexName);
			if (CollectionUtils.isEmpty(addIndex)) {
				//创建子表索引
				configExtMapper.commonCreatIndex(tableName, indexName, indexFromIndexName);
			}
		}
	}
	return true;
}

子表结构生成的SQL,代码如下:

<!--校验子表是否存在 这里db_user写死了数据库名称,后面可以根据实际情况调整-->
<select id="checkTable" resultType="java.lang.Integer" >
	SELECT 1 FROM INFORMATION_SCHEMA.`TABLES` WHERE TABLE_SCHEMA = 'db_user' AND TABLE_NAME = #{tableName};
</select>
<!--创建子表结构-->
<update id="createConfigTable" >
	CREATE TABLE `${newTableName}` LIKE `${sourceName}`;
</update>
<!--获取子表名-->
<select id="getTableInfoList" resultType="java.lang.String">
	SELECT `TABLE_NAME`
	FROM INFORMATION_SCHEMA.`TABLES`
	WHERE `TABLE_NAME` LIKE #{tableName};
</select>
<!--获取主/子表结构列信息 这里db_user写死了数据库名称,后面可以根据实际情况调整-->
<select id="getColumnInfoList" resultType="com.yunxi.datascript.config.ColumnInfo">
	SELECT `COLUMN_NAME` AS columnName
	,COLUMN_DEFAULT AS columnDef   -- 是否默认值
	,IS_NULLABLE AS isNull 		-- 是否允许为空
	,COLUMN_TYPE AS columnType		-- 字段类型
	,COLUMN_COMMENT AS comment	    -- 字段备注
	FROM INFORMATION_SCHEMA.`COLUMNS`
	WHERE TABLE_SCHEMA = 'db_user'
	AND `TABLE_NAME` = #{tableName}
	ORDER BY ORDINAL_POSITION ASC;
</select>
<!--创建子表字段-->
<update id="alterTableColumn" parameterType="com.yunxi.datascript.config.ColumnInfo">
	ALTER TABLE `${tableName}`
	<choose>
		<when test="addColumn">
			ADD COLUMN
		</when >
		<otherwise>
			MODIFY COLUMN
		</otherwise>
	</choose>
	${columnName}
	${columnType}
	<choose>
		<when test="isNull != null and isNull == 'NO'">
			NOT NULL
		</when >
		<otherwise>
			NULL
		</otherwise>
	</choose>
	<if test="columnDef != null and columnDef != ''">
		DEFAULT #{columnDef}
	</if>
	<if test="comment != null and comment != ''">
		COMMENT #{comment}
	</if>
	<if test="alterName != null and alterName != ''">
		AFTER ${alterName}
	</if>
</update>
<!--获取所有索引-->
<select id="getAllIndexNameFromTableName" resultType="java.lang.String">
	SELECT DISTINCT index_name FROM information_schema.statistics WHERE table_name = #{tableName} AND index_name != 'PRIMARY'
</select>
<!--获取拥有索引的列名-->
<select id="getAllIndexFromTableName" resultType="java.lang.String">
	SELECT COLUMN_NAME FROM information_schema.statistics WHERE table_name = #{tableName} AND index_name = #{idxName} AND index_name != 'PRIMARY'
</select>
<!--获取索引名称-->
<select id="findIndexFromTableName" resultType="java.lang.String">
	SELECT index_name FROM information_schema.statistics WHERE table_name = #{tableName} AND index_name = #{idxName}
</select>
<!--创建子表索引-->
<update id="commonCreatIndex">
	CREATE INDEX ${idxName} ON `${tableName}`
	<foreach collection="list" item="item" open="(" close=")" separator=",">
		`${item}`
	</foreach>;
</update>

根据以上关键代码以及实现思路结合实际情况开发出3个接口足以满足日常分表需求了。

🎉 数据迁移

数据迁移通常有两种情况:

第一种是开发人员编码,将数据从一个数据库读取出来,再将数据异步的分批次批量插入另一个库中。

第二种是通过数据库迁移工具,通常使用Navicat for MySQL就可以实现数据迁移。

数据迁移需要注意的是不同数据库语法和实现不同,数据库版本不同,分库分表时数据库的自增主键ID容易出现重复键的问题,通常情况下会在最初需要自增时考虑分布式主键生成策略。

🎉 数据校验

数据校验有对前端传入的参数进行数据校验、有程序插入数据库中的数据进行校验,比如非空校验、长度校验、类型校验、值的范围校验等、有对数据迁移的源数据库和目标数据库的表数据进行对比、这些都是保证数据的完整性。

🍊 读写分离

MySQL读写分离是数据库优化的一种手段,通过将读和写操作分离到不同的数据库服务器上,可以提高数据库的读写性能和负载能力。

🎉 主从数据同步

业务应用发起写请求,将数据写到主库,主库将数据进行同步,同步地复制数据到从库,当主从同步完成后才返回,这个过程需要等待,所以写请求会导致延迟,降低吞吐量,业务应用的数据读从库,这样主从同步完成就能读到最新数据。

🎉 中间件路由

业务应用发起写请求,中间件将数据发往主库,同时记录写请求的key(例如操作表加主键)。当业务应用有读请求过来时,如果key存在,暂时路由到主库,从主库读取数据,在一定时间过后,中间件认为主从同步完成,就会删除这个key,后续读将会读从库。

🎉 缓存路由

缓存路由和中间件路由类似,业务应用发起写请求,数据发往主库,同时缓存记录操作的key,设置缓存的失效时间为主从复制完成的延时时间。如果key存在,暂时路由到主库。如果key不存在,近期没发生写操作,暂时路由到从库。

🌟 Redis调优

🍊 绑定CPU内核

现代计算机的CPU都是多核心多线程,例如i9-12900k有16个内核、24个逻辑处理器、L1缓存1.4MB、L2缓存14MB、L3缓存30MB,一个内核下的逻辑处理器共用L1和L2缓存。

Redis的主线程处理客户端请求、子进程进行数据持久化、子线程处理RDB/AOF rewrite、后台线程处理异步lazy-free和异步释放fd等。这些线程在多个逻辑处理器之间切换,所以为了降低Redis服务端在多个CPU内核上下文切换带来的性能损耗,Redis6.0版本提供了进程绑定CPU 的方式提高性能。

在Redis6.0版本的redis.conf文件配置即可:

  • server_cpulist:RedisServer和IO线程绑定到CPU内核
  • bio_cpulist:后台子线程绑定到CPU内核
  • aof_rewrite_cpulist:后台AOF rewrite进程绑定到CPU内核
  • bgsave_cpulist:后台RDB进程绑定到CPU内核

🍊 使用复杂度过高的命令

Redis有些命令复制度很高,复杂度过高的命令如下:

MSETMSETNXMGETLPUSHRPUSHLRANGELINDEXLSETLINSERTHDELHGETALLHKEYS/HVALSSMEMBERSSUNION/SUNIONSTORESINTER/SINTERSTORESDIFF/SDIFFSTOREZRANGE/ZREVRANGEZRANGEBYSCORE/ZREVRANGEBYSCOREZREMRANGEBYRANK/ZREMRANGEBYSCOREDELKEYS

具体原因有以下:

  • 在内存操作数据的时间复杂度太高,消耗的CPU资源较多。
  • 一些范围命令一次返回给客户端的数据太多,在数据协议的组装和网络传输的过程就要变长,容易延时。
  • Redis虽然使用了多路复用技术,但是复用的还是同一个线程,这一个线程同一时间只能处理一个IO事件,像一个开关一样,当开关拨到哪个IO事件这个电路上,就处理哪个IO事件,所以它单线程处理客户端请求的,如果前面某个命令耗时比较长,后面的请求就会排队,对于客户端来说,响应延迟也会变长。

解决方案:
分批次,每次获取尽量少的数据,数据的聚合在客户端做,减少服务端的压力。

🍊 大key的存储和删除

当存储一个很大的键值对的时候,由于值非常大,所以Redis分配内存的时候就会很耗时,此外删除这个key也是一样耗时,这种key就是大key。开发者可以通过设置慢日志记录有哪些命令比较耗时,命令如下:

命令执行耗时超过10毫秒,记录慢日志

CONFIG SET slowlog-log-slower-than 10000

只保留最近1000条慢日志

CONFIG SET slowlog-max-len 1000

后面再通过SLOWLOG get [n]查看。

对于大key可以通过以下命令直接以类型展示出来,它只显示元素最多的key,但不代表占用内存最多,命令如下:

#-h:redis主机ip
#-p: redis端口号
#-i:隔几秒扫描
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01

对于这种大key的优化,开发者事先在业务实现层就需要避免存储大key,可以在存储的时候将key简化,变成二进制位进行存储,节约redis空间,例如存储上海市静安区,可以对城市和区域进行编码,上海市标记为0,静安区标记为1,组合起来就是01,将01最为key存储起来比上海市静安区作为key存储起来内存占比更小。

可以将大key拆分成多个小key,整个大key通过程序控制多个小key,例如初始阶段,业务方只需查询某乡公务员姓名。然而,后续需求拓展至县、市、省。开发者未预见此增长,将数据存储于单个键中,导致键变成大键,影响系统性能。现可将大键拆分成多个小键,如省、市、县、乡,使得每级行政区域的公务员姓名均对应一个键。

根据Redis版本不同处理方式也不同,4.0以上版本可以用unlink代替del,这样可以把key释放内存的工作交给后台线程去执行。6.0以上版本开启lazy-free后,执行del命令会自动地在后台线程释放内存。

使用List集合时通过控制列表保存元素个数,每个元素长度触发压缩列表(ziplist)编码,压缩列表是有顺序并且连续的内存块组成的一种专门节约内存的存储结构,通过在redis.conf(linux系统)或者redis.windows.conf(windows系统)文件里面修改以下配置实现:

list-max-ziplist-entries 512
list-max-ziplist-value 64

🍊 数据集中过期

在某个时段,大量关键词(key)会在短时间内过期。当这些关键词过期时,访问Redis的速度会变慢,因为过期数据被惰性删除(被动)和定期删除(主动)策略共同管理。惰性删除是在获取关键词时检查其是否过期,一旦过期就删除。

这意味着大量过期关键词在使用之前并未删除,从而持续占用内存。主动删除则是在主线程执行,每隔一段时间删除一批过期关键词。若出现大量需要删除的过期关键词,客户端访问Redis时必须等待删除完成才能继续访问,导致客户端访问速度变慢。

这种延迟在慢日志中无法查看,经验不足的开发者可能无法定位问题,因为慢日志记录的是操作内存数据所需时间,而主动删除过期关键词发生在命令执行之前,慢日志并未记录时间消耗。

因此,当开发者感知某个关键词访问变慢时,实际上并非该关键词导致,而是Redis在删除大量过期关键词所花费的时间。
(1)开发者检查代码,找到导致集中过期key的逻辑,并设置一个自定义的随机过期时间分散它们,从而避免在短时间内集中删除key。

(2)在Redis 4.0及以上版本中,引入了Lazy Free机制,使得删除键的操作可以在后台线程中执行,不会阻塞主线程。

(3)使用Redis的Info命令查看Redis运行的各种指标,重点关注expired_keys指标。这个指标在短时间内激增时,可以设置报警,通过短信、邮件、微信等方式通知运维人员。它的作用是累计删除过期key的数量。当指标突增时,通常表示大量过期key在同一时间被删除。

🍊 内存淘汰策略

当Redis的内存达到最大容量限制时,新的数据将先从内存中筛选一部分旧数据以腾出空间,从而导致写操作的延迟。这是由内存淘汰策略所决定的。

常见的两种策略为淘汰最少访问的键(LFU)和淘汰最长时间未访问的键(LRU)。

  • LRU策略可能导致最近一段时间的访问数据未被访问而突然成为热点数据。
  • LFU策略可能导致前一段时间访问次数很多,但最近一段时间未被访问,导致冷数据无法被淘汰。

尽管LFU策略的性能优于LRU策略,但具体选择哪种策略需要根据实际业务进行调整。对于商品搜索和热门推荐等场景,通常只有少量数据被访问,大部分数据很少被访问,可以使用LFU策略。对于用户最近访问的页面数据可能会被二次访问的场景,则适合使用LRU策略。

除了选择淘汰策略外,还可以通过拆分多个实例或横向扩展来分散淘汰过期键的压力。

如果效果仍不理想,开发者可以编写淘汰过期键功能,设置定时任务,在凌晨不繁忙时段主动触发淘汰,删除过期键。

🍊 碎片整理

Redis存储在内存中必然会出现频繁修改的情况,而频繁的修改Redis数据会导致Redis出现内存碎片,从而导致Redis的内存使用率减低。

通常情况下在4.0以下版本的Redis只能通过重启解决内存碎片,而4.0及以上版本可以开启碎片自动整理解决,只不过碎片整理是在

主线程中完成的,通用先对延时范围和时间进行评估,然后在机器负载不高同时业务不繁忙时开启内存碎片整理,避免影响客户端请求。

开启内存自动碎片整理配置如下:

# 已启用活动碎片整理
activedefrag yes
# 启动活动碎片整理所需的最小碎片浪费量
active-defrag-ignore-bytes 100mb
# 启动活动碎片整理的最小碎片百分比
active-defrag-threshold-lower 10
# 使用最大努力的最大碎片百分比
active-defrag-threshold-upper 100
# 以CPU百分比表示的碎片整理工作量最小
active-defrag-cycle-min 5
# 以CPU百分比表示的碎片整理最大工作量
active-defrag-cycle-max 75
# 将从主字典扫描中处理的集合/哈希/zset/列表字段的最大数目
active-defrag-max-scan-fields 1000

🍊 内存大页

自Linux内核2.6.38版本起,Redis可申请以2MB为单位的内存,从而降低内存分配次数,提高效率。然而,由于每次分配的内存单位增大,处理时间也相应增加。在进行RDB和AOF持久化时,Redis主进程先创建子进程,子进程将内存快照写入磁盘,而主进程继续处理写请求。数据变动时,主进程将新数据复制到一块新内存,并修改该内存块。

读写分离设计允许并发写入,无需加锁,但在主进程上进行内存复制和申请新内存会增加处理时间,影响性能。大key可能导致申请更大的内存和更长的处理时间。根据项目实际情况,关闭Redis部署机器上的内存大页机制以提高性能是一种不错的选择。

🍊 数据持久化与AOF刷盘

Redis提供三种持久化方式:RDB快照、AOF日志和混合持久化。默认使用RDB快照。

(1)RDB快照:周期性生成dump.rdb文件,主线程fork子线程,子线程处理磁盘IO,处理RDB快照,主线程fork线程的过程可能会阻塞主线程,主线程内存越大阻塞越久,可能导致服务暂停数秒。

(2)AOF日志:每条写入命令追加,回放日志重建数据。文件过大时,会去除没用的指令,定期根据内存最新数据重新生成aof文件。默认1秒执行一次fsync操作,最多丢失1秒数据。在AOF刷盘时,如果磁盘IO负载过高,fsync可能会阻塞主线程,主线程继续接收写请求,把数据写到文件内存里面,写操作需要等fsync执行完才可以继续执行。

(3)混合持久化:RDB快照模式恢复速度快,但可能丢失部分数据。AOF日志文件通常比RDB数据快照文件大,支持的写QPS较低。将两种持久化模式混合使用,AOF保证数据不丢失,RDB快速数据恢复,混合持久化重写时,将内存数据转换为RESP命令写入AOF文件,结合RDB快照和增量AOF修改。新文件一开始不叫appendonly.aof,重写完成后改名,覆盖原有AOF文件。先加载RDB,再重放AOF。

三种持久化方式都存在问题:fork操作可能阻塞主线程;磁盘IO负载过大时,fork阻塞影响AOF写入文件内存。

原因:fork创建的子进程复制父进程的空间内存页表,fork耗时跟进程总内存量有关,OPS越高耗时越明显。

解决方案:

(1)可以通过info stats命令查看latest_fork_usec指标观察最近一次fork操作耗时进行问题辅助定位。

(2)减少fork频率,根据实际情况适当地调整AOF触发条件

(3)Linux内存分配策略默认配置是vm.overcommit_memory=0,表示内存不足时,不会分配,导致fork阻塞。改成1,允许过量使用,直到内存用完为止。

(4)评估Redis最大可用内存,让机器至少有20%的闲置内存。

(5)定位占用磁盘IO较大的应用程序,将该应用程序移到其他机器上去,减少对Redis影响。

(6)资金充足情况下,更换高性能的SSD磁盘,从硬件层面提高磁盘IO处理能力。

(7)配置no-appendfsync-on-rewrite none表示AOF写入文件内存时,不触发fsync,不执行刷盘。这种调整有一定风险,如果Redis在AOF写入文件内存时刚好挂了,存在数据丢失情况。

🍊 丢包/中断/CPU亲和性

网络因素有以下问题:

(1)网络宽带和流量是否瓶颈、数据传输延迟和丢包情况、是否频繁短连接(如TCP创建和断开)

(2)数据丢包情况:数据丢包通常发生在网卡设备驱动层面,网卡收到数据包,将数据包从网卡硬件缓存转移到服务器内存中,通知内核处理,经过TCP/IP协议校验、解析、发送给上层协议,应用程序通过read系统调用从socket buffer将新数据从内核区拷贝到用户区读取数据。

TCP能动态调整接收窗口大小,不会出现由于socket buffer接收队列空间不足而丢包的情况。

然而在高负载压力下,网络设备的处理性能达到硬件瓶颈,网络设备和内核资源出现竞争和冲突,网络协议栈无法有效地处理和转发数据包,传输速度受限,而Linux使用缓冲区来缓存接收到的数据包,大量数据包涌入内核缓冲区,可能导致缓冲区溢出,进而影响数据包的处理和传输,内核无法处理所有收到的数据包,处理速度跟不上收包速度,导致数据包丢失。

(3)Redis的数据通常存储在内存中,通过网络和客户端进行交互。在这个过程中,Redis可能会受到中断的影响,因为中断可能会打断Redis的正常执行流程。当CPU正在处理Redis的调用时,如果发生了中断,CPU必须停止当前的工作转而处理中断请求。在处理中断的过程中,Redis无法继续运行,必须等待中断处理完毕后才能继续运行。这会导致Redis的响应速度受到影响,因为在等待中断处理的过程中,Redis无法响应其他请求。

(4)在NUMA架构中,每个CPU内核对应一个NUMA节点。中断处理和网络数据包处理涉及多个CPU内核和NUMA节点。Linux内核使用softnet_data数据结构跟踪网络数据包的处理状态,以实现更高效的数据处理和调度。在处理网络数据包时,内核首先在softnet_data中查找相关信息,然后根据这些信息执行相应操作,如发送数据包、重新排序数据包等。

网络驱动程序使用内核分配的缓冲区(sk_buffer)存储和处理网络数据包,当网络设备收到数据包时,会向驱动程序发送中断信号,通知其处理新数据包。驱动程序从设备获取数据包,并将其添加到sk_buffer缓冲区。内核会继续处理sk_buffer中的数据包,如根据协议类型进行分拣、转发或丢弃等。

softnet_data和sk_buffer缓冲区都可能跨越NUMA节点,在数据接收过程中,数据从NUMA节点的一个节点传递到另一个节点时,由于数据跨越了不同的节点,不仅无法利用L2和L3缓存还需要在节点之间进行数据拷贝,导致数据在传输过程中的额外开销,进而增加了传输时间和响应时间,性能下降。

(5)Linux的CPU亲和性特性也会影响进程的调度。当一个进程唤醒另一个的时候,被唤醒的进程可能会被放到相同的CPU core或者相同的NUMA节点上。当多个NUMA node处理中断时,可能导致Redis进程在CPU core之间频繁迁移,造成性能损失。
解决方案:

(1)升级网络设备或增加网络设备的数量,以提高网络处理能力和带宽。

(2)适当调整Linux内核缓冲区的大小,以平衡网络处理能力和数据包丢失之间的关系。

(3)将中断都分配到同一NUMA Node中,中断处理函数和Redis利用同NUMA下的L2、L3缓存、同节点下的内存,降低延迟。

(4)结合Linux的CPU亲和性特性,将任务或进程固定到同一CPU内核上运行,提高系统性能和效率,保证系统稳定性和可靠性。

注意:在Linux系统中NUMA亲和性可以指定在哪个NUMA节点上运行,Redis在默认情况下并不会自动将NUMA亲和性配置应用于实例部署,通常情况下通过使用Kubernetes等容器编排工具,调整节点亲和性策略或使用pod亲和性和节点亲和性规则来控制Redis实例在特定NUMA节点上运行。或者在手动部署Redis实例时,使用Linux系统中的numactl命令来查看和配置NUMA节点信息,将Redis实例部署在某个NUMA节点上。如果是在虚拟化环境中,使用NUMA aware虚拟机来部署Redis实例,让它在指定的NUMA节点上运行。

(5)添加网络流量阈值预警,超限时通知运维人员,及时扩容。

(6)编写监控脚本,正确配置和使用监控组件,使用长连接收集Redis状态信息,避免短连接。

(7)为Redis机器分配专用资源,避免其他程序占用。

🍊 操作系统Swap与主从同步

Redis突然变得很慢,需要考虑Redis是否使用操作系统的Swap以缓解内存不足的影响,它允许把部分内存数据存储到磁盘上,而访问磁盘速度比访问内存慢很多,所以操作系统的Swap对Redis的延时是无法接受的。

解决方案:

(1)适当增加Redis服务器的内存

(2)对Redis的内存碎片进行整理

(3)同时当Redis内存不足或者使用了Swap时,通过邮件、短信、微信等渠道通知运维人员及时处理

(4)主从架构的Redis在释放Swap前先将主节点切换至新主节点,旧主节点释放Swap后重启,待从库数据完全同步后再行主从切换,以避免影响应用程序正常运行。

在主从架构数据同步过程中,可能因网络中断或IO异常导致连接中断。建议使用支持数据断点续传的2.8及以上版本,以避免对整份数据进行复制,降低性能浪费。

🍊 监控

在Redis的监控中,有两种推荐的体系:ELK和Fluent + Prometheus + Grafana。

ELK体系通常使用metricbeat作为指标采集,logstash作为收集管道,并通过可视化工具kibana来呈现数据。ElasticSearch用于存储监控数据。

Fluent + Prometheus + Grafana体系则使用redis-eport作为指标采集,fluentd作为采集管道,并通过可视化工具Grafana来展示数据。Prometheus用于存储监控数据。

这两种监控体系都可以获取Redis的各项指标,并对数据进行持续化存储和对比。可视化工具使得开发者和运维人员能够更清晰地观察Redis集群的运行状况,如内存消耗、集群信息、请求键命中率、客户端连接数、网络指标、内存监控等。此外,它们都支持预警机制,例如设置慢查询日志阈值来监控慢日志个数和最长耗时,超出阈值则通过短信、微信、邮件等方式进行报警通知。这样,有了监控系统后,就可以快速发现问题、定位故障,并协助运维人员进行资源规划、性能观察等操作。

🍊 高可用

上述提到的主从同步和哨兵机制可以保证Redis服务的高可用,还有多级缓存、冷热分离可以保证高可用。

商品详情页在电商平台的秒杀场景中,涉及商品信息的动态展示和高并发访问,需要通过一系列手段保证系统的高并发和高可用,通过采用Nginx+Lua架构、CDN缓存、本地应用缓存和分布式缓存等多种技术手段,实现了商品详情页的动态化和缓存优化,提高用户访问商品详情页的速度和体验。同时,通过开关前置化和缓存过期机制,确保了缓存数据的有效性,降低了对后端数据库的访问压力。

🎉 主从同步和哨兵机制

主从复制通常采用异步方式,可能导致主节点数据尚未完全复制至从节点,主节点便已故障,导致数据丢失。因此,需要控制复制数据的时长和ACK延迟,降低数据丢失风险。

主从切换过程通常使用哨兵机制。但在主节点正常运行时,可能因与某从节点连接中断,哨兵误判主节点已故障。在此情况下,哨兵可能启动选举,将某从节点升级为主节点,导致集群出现两个主节点,发生脑裂。旧主节点恢复网络后,将被升级为从节点并挂载至新主节点,导致自身数据丢失,并需从新主节点复制数据。而新主节点并未包含后续客户端写入的数据,导致这些数据丢失。为降低数据丢失风险,可设置连接主节点最少的从节点数量和从节点连接主节点最大的延迟时间,若主节点与从节点断开连接,且从节点超过阈值时间未收到ACK消息,则拒绝客户端的写请求,将数据丢失控制在可控范围。

🎉 多级缓存

Java多级缓存是一种常见的优化策略,可以有效地提高系统的性能和响应速度。

📝 1.浏览器缓存

在页面间跳转时,从本地缓存获取数据;或在打开新页面时,根据Last-Modified头来CDN验证数据是否过期,减少数据传输量。
CDN缓存当用户点击商品图片或链接时,从最近的CDN节点获取数据,而非回源到北京机房,提升访问性能。

📝 2.服务端应用本地缓存

采用Nginx+Lua架构,通过HttpLuaModule模块的shared dict或内存级Proxy Cache来减少带宽。

📝 3.一致性哈希

在电商场景中,使用商品编号/分类作为哈希键,提高URL命中率。

📝 4.mget优化

根据商品的其他维度数据(如分类、面包屑、商家等),先从本地缓存读取,如不命中则从远程缓存获取。这个优化减少了一半以上的远程缓存流量。

📝 5.服务端缓存

(1)将缓存存储在内存、SSD和JIMDB中,实现读写性能和持久化的平衡。

(2)对热门商品和访问量较大的页面进行缓存,降低数据库压力。

(3)使用Nignx缓存:存储数据量少但访问量高的热点数据,例如双11或者618活动。

(4)使用JVM本地缓存:存储数据量适中访问量高的热点数据,例如网站首页数据。

(5)使用Redis缓存:存储数据量很大,访问量较高的普通数据,例如商品信息。

📝 6.商品详情页数据获取

(1)用户打开商品详情页时,先从本地缓存获取基本数据,如商品ID、商品名称和价格等。

(2)根据用户浏览历史和搜索记录,动态加载其他维度数据,如分类、商家信息和评论等。

📝 7.Nginx+Lua架构

(1)使用Nginx作为反向代理和负载均衡器,将请求转发给后端应用。

(2)使用Lua脚本实现动态页面渲染,并对商品详情页数据进行缓存。

(3)重启应用秒级化,重启速度快,且不会丢失共享字典缓存数据。

(4)需求上线速度化,可以快速上线和重启应用,减少抖动。

(5)在Nginx上做开关,设置缓存过期时间,当缓存数据过期时,强制从后端应用获取最新数据,并更新缓存。

🎉 冷热分离

冷热分离的具体步骤:

(1)分析现有系统的数据类型和访问模式,了解各类数据的冷热程度。

(2)确定合适的冷热分离策略和方案,以优化数据存储和管理。

(3)设计冷热分离架构,为热数据和冷数据选择合适的存储介质、存储策略以及数据同步机制。

(4)将冷数据从热存储介质迁移到冷存储介质,可以采用全量迁移和增量迁移的方式。

(5)对热数据进行有效管理,包括访问控制、数据安全、性能监控等,以确保数据的安全性和可用性。

(6)对冷数据进行持久化、备份、归档等操作,以防止数据丢失并确保数据的可恢复性。

(7)设计合适的故障转移和恢复策略,如主从复制、多副本存储、故障检测与恢复等,以确保系统在故障或恢复时的稳定运行。

(8)在冷热分离后对系统性能进行优化,包括优化热存储介质的性能监控、调整存储结构、调整缓存策略等。

(9)持续监控数据同步、性能指标、故障排查与修复,确保系统的稳定运行。

以实际案例进行说明:

案例1:在线购物网站的商品库存管理系统

(1)热数据:用户频繁访问的商品信息,如商品名称、价格、库存量等,需要快速响应和低延迟。

(2)冷数据:用户访问较少的商品信息,对响应速度要求较低,但对数据安全和完整性要求较高。如商品的详细描述、评价、历史价格等。

案例2:在线音乐平台的曲库管理系统

(1)热数据:用户经常访问的热门歌曲,如排行榜前10名、新上架的歌曲等,存储在高速且高可靠性的SSD硬盘Redis缓存中,以确保快速的数据访问和响应速度。

(2)冷数据:用户较少访问的歌曲,如过时的经典歌曲、小众音乐等,存储在低成本且大容量的存储介质(HDFS、Ceph、S3)中,以节省成本并存储大量历史数据。

案例3:在线求职招聘网站的职位信息管理系统

(1)热数据:用户经常访问的热门职位信息,如招聘需求高的职位、高薪职位、职位信息的基本描述、薪资范围、投递人数等。

(2)冷数据:用户较少访问的职位信息,如停招职位的详细描述、过期职位、历史招聘情况等。

小结:

在冷数据(如历史数据、归档数据等)存储场景中,使用RocksDB作为Key-Value分布式存储引擎,存储大量数据,进行数据备份和恢复,以确保在故障或系统恢复时能够快速恢复数据,节省成本并提高存储空间利用率。

在热数据(如实时更新的数据、用户操作日志等)存储场景中,使用Redis缓存支持各种高并发场景,提升响应速度。
通过以上步骤,可以有效地对冷热数据进行分离,从而实现更高效、更安全的数据存储和管理。

🎉 缓存雪崩、穿透、击穿、热点缓存重构、缓存失效

从前,有一个叫做小明的程序员,他的网站被越来越多的用户访问,于是他决定使用Redis缓存来提高网站性能。
一天,大雪纷飞,小明的服务器突然停机了。当服务器重新启动后,所有的缓存都失效了。这就是Redis缓存雪崩的场景。

为了避免Redis缓存雪崩,小明决定使用多级缓存和缓存预热等技术手段。他设置了多个Redis实例,同时监听同一个缓存集群。当一个实例出现问题时,其他实例可以顶替它的功能。并且,他在低访问时间段主动向缓存中写入数据,以提前预热缓存。
然而,小明并没有想到缓存穿透的问题。有些用户在请求缓存中不存在的数据时,会频繁地向数据库查询,从而拖慢服务器响应时间。这就是Redis缓存穿透的场景。

为了避免Redis缓存穿透,小明决定使用布隆过滤器等技术手段。布隆过滤器可以高效地过滤掉不存在的数据,从而减少数据库查询次数。

不久之后,小明又遇到了缓存击穿的问题。某一个热门商品被多个用户同时请求,导致缓存无法承受压力,最终请求直接打到了数据库。这就是Redis缓存击穿的场景。

为了避免Redis缓存击穿,小明决定使用分布式锁等技术手段。分布式锁可以保证同一时间只有一个用户请求数据库,避免了缓存被高并发压垮的情况。

最后,小明遇到了缓存热点重构的问题。某一个商品的热度突然升高,导致缓存集中在这个商品上,其他商品的缓存无法承受压力。这就是缓存热点重构的场景。

为了避免缓存热点重构,小明决定使用数据预热等技术手段。他在缓存中设置过期时间,同时在低访问时间段主动重构热点商品的缓存,以避免缓存集中在某一个商品上。

技术解决方案和手段:

(1)多级缓存和缓存预热:适用于缓存雪崩场景,可以提前将数据存储到缓存中,避免缓存雪崩。

(2)布隆过滤器:适用于缓存穿透场景,可以高效地过滤不存在的数据,减少数据库查询次数。

(3)分布式锁:适用于缓存击穿场景,可以保证同一时间只有一个用户请求数据库,避免了缓存被高并发压垮的情况。

(4)数据预热:适用于缓存热点重构场景,可以在低访问时间段主动重构热点商品的缓存,避免缓存集中在某一个商品上。

优缺点对比:

(1)多级缓存和缓存预热:优点是能够提前将数据存储到缓存中,避免缓存雪崩;缺点是需要占用更多的内存空间,同时预热时间过长可能会拖慢服务器响应速度。

(2)布隆过滤器:优点是可以高效地过滤不存在的数据,减少数据库查询次数;缺点是无法完全避免缓存穿透,同时需要占用一定的内存空间。

(3)分布式锁:优点是可以保证同一时间只有一个用户请求数据库,避免了缓存被高并发压垮的情况;缺点是会增加系统的复杂度,可能引入单点故障等问题。

(4)数据预热:优点是可以避免缓存热点重构的问题;缺点是需要占用更多的内存空间,同时需要在低访问时间段主动重构缓存。

总之,不同的技术解决方案和手段都有其优缺点。程序员需要根据实际情况选择适合自己的方案,并且不断地优化和改进,以提高系统的性能和稳定性。

🌟 消息中间件调优

🍊 三种mq对比

使用消息队列有解耦,扩展性,削峰,异步等功能,市面上主流的几款mq,rabbitmq,rocketmq,kafka有各自的应用场景。kafka,有出色的吞吐量,比较强悍的性能,而且集群可以实现高可用,就是会丢数据,所以一般被用于日志分析和大数据采集。rabbitmq,消息可靠性比较高,支持六种工作模式,功能比较全面,但是由于吞吐量比较低,消息累积还会影响性能,加上erlang语言不好定制,所以一般使用于小规模的场景,大多数是中小企业用的比较多。rocketmq,高可用,高性能,高吞吐量,支持多种消息类型,比如同步,异步,顺序,广播,延迟,批量,过滤,事务等等消息,功能比较全面,只不过开源版本比不上商业版本的,加上开发这个中间件的大佬写的文档不多,文档不太全,这也是它的一个缺点,不过这个中间件可以作用于几乎全场景。

🍊 消息丢失

消息丢失,生产者往消息队列发送消息,消息队列往消费者发送消息,会有丢消息的可能,消息队列也有可能丢消息,通常MQ存盘时都会先写入操作系统的缓存页中,然后再由操作系统异步的将消息写入硬盘,这个中间有个时间差,就可能会造成消息丢失,如果服务挂了,缓存中还没有来得及写入硬盘的消息就会发生消息丢失。

不同的消息中间件对于消息丢失也有不同的解决方案,先说说最容易丢失消息的kafka吧。生产者发消息给Kafka Broker:消息写入Leader后,Follower是主动与Leader进行同步,然后发ack告诉生产者收到消息了,这个过程kafka提供了一个参数,request.required.acks属性来确认消息的生产。

0表示不进行消息接收是否成功的确认,发生网络抖动消息丢了,生产者不校验ACK自然就不知道丢了。

1表示当Leader接收成功时确认,只要Leader存活就可以保证不丢失,保证了吞吐量,但是如果leader挂了,恰好选了一个没有ACK的follower,那也丢了。

-1或者all表示Leader和Follower都接收成功时确认,可以最大限度保证消息不丢失,但是吞吐量低,降低了kafka的性能。一般在不涉及金额的情况下,均衡考虑可以使用1,保证消息的发送和性能的一个平衡。

Kafka Broker 消息同步和持久化:Kafka通过多分区多副本机制,可以最大限度保证数据不会丢失,如果数据已经写入系统缓存中,但是还没来得及刷入磁盘,这个时候机器宕机,或者没电了,那就丢消息了,当然这种情况很极端。Kafka Broker 将消息传递给消费者:如果消费这边配置的是自动提交,万一消费到数据还没处理完,就自动提交offset了,但是此时消费者直接宕机了,未处理完的数据丢失了,下次也消费不到了。所以为了避免这种情况,需要将配置改为,先消费处理数据,然后手动提交,这样消息处理失败,也不会提交成功,没有丢消息。

rabbitmq整个消息投递的路径是producer—>rabbitmq broker—>exchange—>queue—>consumer。

生产者将消息投递到Broker时产生confirm状态,会出现二种情况,ack:表示已经被Broker签收。nack:表示表示已经被Broker拒收,原因可能有队列满了,限流,IO异常等。生产者将消息投递到Broker,被Broker签收,但是没有对应的队列进行投递,将消息回退给生产者会产生return状态。

这二种状态是rabbitmq提供的消息可靠投递机制,生产者开启确认模式和退回模式。使用rabbitTemplate.setConfirmCallback设置回调函数。当消息发送到exchange后回调confirm方法。在方法中判断ack,如果为true,则发送成功,如果为false,则发送失败,需要处理。

使用rabbitTemplate.setReturnCallback设置退回函数,当消息从exchange路由到queue失败后,如果设置了rabbitTemplate.setMandatory(true)参数,则会将消息退回给producer。消费者在rabbit:listener-container标签中设置acknowledge属性,设置ack方式 none:自动确认,manual:手动确认。

none自动确认模式很危险,当生产者发送多条消息,消费者接收到一条信息时,会自动认为当前发送的消息已经签收了,这个时候消费者进行业务处理时出现了异常情况,也会认为消息已经正常签收处理了,而队列里面显示都被消费掉了。所以真实开发都会改为手动签收,可以防止消息丢失。

消费者如果在消费端没有出现异常,则调用channel.basicAck方法确认签收消息。消费者如果出现异常,则在catch中调用 basicNack或 basicReject,拒绝消息,让MQ重新发送消息。通过一系列的操作,可以保证消息的可靠投递以及防止消息丢失的情况。
在这里插入图片描述

然后说一下rocketmq,生产者使用事务消息机制保证消息零丢失,第一步就是确保Producer发送消息到了Broker这个过程不会丢消息。发送half消息给rocketmq,这个half消息是在生产者操作前发送的,对下游服务的消费者是不可见的。这个消息主要是确认RocketMQ的服务是否正常,通知RocketMQ,马上要发一个消息了,做好准备。

half消息如果写入失败就认为MQ的服务是有问题的,这个时候就不能通知下游服务了,给生产者的操作加上一个状态标记,然后等待MQ服务正常后再进行补偿操作,等MQ服务正常后重新下单通知下游服务。

然后执行本地事务,比如说下了个订单,把下单数据写入到mysql,返回本地事务状态给rocketmq,在这个过程中,如果写入数据库失败,可能是数据库崩了,需要等一段时间才能恢复,这个时候把订单一直标记为"新下单"的状态,订单的消息先缓存起来,比如Redis、文本或者其他方式,然后给RocketMQ返回一个未知状态,未知状态的事务状态回查是由RocketMQ的Broker主动发起的,RocketMQ过一段时间来回查事务状态,在回查事务状态的时候,再尝试把数据写入数据库,如果数据库这时候已经恢复了,继续后面的业务。

而且即便这个时候half消息写入成功后RocketMQ挂了,只要存储的消息没有丢失,等RocketMQ恢复后,RocketMQ就会再次继续状态回查的流程。

第二步就是确保Broker接收到的消息不会丢失,因为RocketMQ为了减少磁盘的IO,会先将消息写入到os缓存中,不是直接写入到磁盘里面,消费者从os缓存中获取消息,类似于从内存中获取消息,速度更快,过一段时间会由os线程异步的将消息刷入磁盘中,此时才算真正完成了消息的持久化。在这个过程中,如果消息还没有完成异步刷盘,RocketMQ中的Broker宕机的话,就会导致消息丢失。

所以第二步,消息支持持久化到Commitlog里面,即使宕机后重启,未消费的消息也是可以加载出来的。把RocketMQ的刷盘方式 flushDiskType配置成同步刷盘,一旦同步刷盘返回成功,可以保证接收到的消息一定存储在本地的内存中。采用主从机构,集群部署,Leader中的数据在多个Follower中都存有备份,防止单点故障,同步复制可以保证即使Master 磁盘崩溃,消息仍然不会丢失。但是这里还会有一个问题,主从结构是只做数据备份,没有容灾功能的。也就是说当一个master节点挂了后,slave节点是无法切换成master节点继续提供服务的。

所以在RocketMQ4.5以后的版本支持Dledge,DLedger是基于Raft协议选举Leader Broker的,当master节点挂了后,Dledger会接管Broker的CommitLog消息存储 ,在Raft协议中进行多台机器的Leader选举,发起一轮一轮的投票,通过多台机器互相投票选出来一个Leader,完成master节点往slave节点的消息同步。

数据同步会通过两个阶段,一个是uncommitted阶段,一个是commited阶段。Leader Broker上的Dledger收到一条数据后,会标记为uncommitted状态,然后他通过自己的DledgerServer组件把这个uncommitted数据发给Follower Broker的DledgerServer组件。

接着Follower Broker的DledgerServer收到uncommitted消息之后,必须返回一个ack给Leader Broker的Dledger。然后如果Leader Broker收到超过半数的Follower Broker返回的ack之后,就会把消息标记为committed状态。再接下来, Leader Broker上的DledgerServer就会发送committed消息给Follower Broker上的DledgerServer,让他们把消息也标记为committed状态。这样,就基于Raft协议完成了两阶段的数据同步。

第三步,Cunmser确保拉取到的消息被成功消费,就需要消费者不要使用异步消费,有可能造成消息状态返回后消费者本地业务逻辑处理失败造成消息丢失的可能。用同步消费方式,消费者端先处理本地事务,然后再给MQ一个ACK响应,这时MQ就会修改Offset,将消息标记为已消费,不再往其他消费者推送消息,在Broker的这种重新推送机制下,消息是不会在传输过程中丢失的。

🍊 消息重复消费

消息重复消费的问题

第一种情况是发送时消息重复,当一条消息已被成功发送到服务端并完成持久化,此时出现了网络抖动或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

第二种情况是投递时消息重复,消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。 为了保证消息至少被消费一次,tMQ 的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

第三种情况是负载均衡时消息重复,比如网络抖动、Broker 重启以及订阅方应用重启,当MQ的Broker或客户端重启、扩容或缩容时,会触发Rebalance,此时消费者可能会收到重复消息。

那么怎么解决消息重复消费的问题呢?就是对消息进行幂等性处理。

在MQ中,是无法保证每个消息只被投递一次的,因为网络抖动或者客户端宕机等其他因素,基本都会配置重试机制,所以要在消费者端的业务上做消费幂等处理,MQ的每条消息都有一个唯一的MessageId,这个参数在多次投递的过程中是不会改变的,业务上可以用这个MessageId加上业务的唯一标识来作为判断幂等的关键依据,例如订单ID。而这个业务标识可以使用Message的Key来进行传递。消费者获取到消息后先根据id去查询redis/db是否存在该消息,如果不存在,则正常消费,消费完后写入redis/db。如果存在,则证明消息被消费过,直接丢弃。

🍊 消息顺序

消息顺序的问题,如果发送端配置了重试机制,mq不会等之前那条消息完全发送成功,才去发送下一条消息,这样可能会出现发送了1,2,3条消息,但是第1条超时了,后面两条发送成功,再重试发送第1条消息,这时消息在broker端的顺序就是2,3,1了。RocketMQ消息有序要保证最终消费到的消息是有序的,需要从Producer、Broker、Consumer三个步骤都保证消息有序才行。

在发送者端:在默认情况下,消息发送者会采取Round Robin轮询方式把消息发送到不同的分区队列,而消费者消费的时候也从多个MessageQueue上拉取消息,这种情况下消息是不能保证顺序的。而只有当一组有序的消息发送到同一个MessageQueue上时,才能利用MessageQueue先进先出的特性保证这一组消息有序。

而Broker中一个队列内的消息是可以保证有序的。在消费者端:消费者会从多个消息队列上去拿消息。这时虽然每个消息队列上的消息是有序的,但是多个队列之间的消息仍然是乱序的。消费者端要保证消息有序,就需要按队列一个一个来取消息,即取完一个队列的消息后,再去取下一个队列的消息。而给consumer注入的MessageListenerOrderly对象,在RocketMQ内部就会通过锁队列的方式保证消息是一个一个队列来取的。

MessageListenerConcurrently这个消息监听器则不会锁队列,每次都是从多个Message中取一批数据,默认不超过32条。因此也无法保证消息有序。

RocketMQ 在默认情况下不保证顺序,要保证全局顺序,需要把 Topic 的读写队列数设置为 1,然后生产者和消费者的并发设置也是 1,不能使用多线程。所以这样的话高并发,高吞吐量的功能完全用不上。全局有序就是无论发的是不是同一个分区,我都可以按照你生产的顺序来消费。分区有序就只针对发到同一个分区的消息可以顺序消费。

kafka保证全链路消息顺序消费,需要从发送端开始,将所有有序消息发送到同一个分区,然后用一个消费者去消费,但是这种性能比较低,可以在消费者端接收到消息后将需要保证顺序消费的几条消费发到内存队列(可以搞多个),一个内存队列开启一个线程顺序处理消息。

RabbitMq没有属性设置消息的顺序性,不过我们可以通过拆分为多个queue,每个queue由一个consumer消费。或者一个queue对应一个consumer,然后这个consumer内部用内存队列做排队,然后分发给底层不同的worker来处理,保证消息的顺序性。

🍊 消息积压

线上有时因为发送方发送消息速度过快,或者消费方处理消息过慢,可能会导致broker积压大量未消费消息。消息数据格式变动或消费者程序有bug,导致消费者一直消费不成功,也可能导致broker积压大量未消费消息。解决方案可以修改消费端程序,让其将收到的消息快速转发到其他主题,可以设置很多分区,然后再启动多个消费者同时消费新主题的不同分区。可以将这些消费不成功的消息转发到其它队列里去,类似死信队列,后面再慢慢分析死信队列里的消息处理问题。

在RocketMQ官网中,还分析了一个特殊情况,如果RocketMQ原本是采用的普通方式搭建主从架构,而现在想要中途改为使用Dledger高可用集群,这时候如果不想历史消息丢失,就需要先将消息进行对齐,也就是要消费者把所有的消息都消费完,再来切换主从架构。因为Dledger集群会接管RocketMQ原有的CommitLog日志,所以切换主从架构时,如果有消息没有消费完,这些消息是存在旧的CommitLog中的,就无法再进行消费了。这个场景下也是需要尽快的处理掉积压的消息。

🍊 延迟队列

消息被发送以后,并不想让消费者立刻获取,而是等待特定的时间后,消费者才能获取这个消息进行消费。例如10分钟,内完成订单支付,支付完成后才会通知下游服务进行进一步的营销补偿。往MQ发一个延迟1分钟的消息,消费到这个消息后去检查订单的支付状态,如果订单已经支付,就往下游发送下单的通知。而如果没有支付,就再发一个延迟1分钟的消息。最终在第10个消息时把订单回收,就不用对全部的订单表进行扫描,而只需要每次处理一个单独的订单消息。这个就是延迟对列的应用场景。

rabbittmq,rocketmq都可以通过设置ttl来设置延迟时间,kafka则是可以在发送延时消息的时候,先把消息按照不同的延迟时间段发送到指定的队列中,比如topic_1s,topic_5s,topic_10s,topic_2h,然后通过定时器进行轮训消费这些topic,查看消息是否到期,如果到期就把这个消息发送到具体业务处理的topic中,队列中消息越靠前的到期时间越早,具体来说就是定时器在一次消费过程中,对消息的发送时间做判断,看下是否延迟到对应时间了,如果到了就转发,如果还没到这一次定时任务就可以提前结束了。
mq设置过期时间,就会有消息失效的情况,如果消息在队列里积压超过指定的过期时间,就会被mq给清理掉,这个时候数据就没了。解决方案也有手动写程序,将丢失的那批数据,一点点地查出来,然后重新插入到 mq 里面去。

🍊 消息队列高可用

对于RocketMQ来说可以使用Dledger主从架构来保证消息队列的高可用,这个在上面也有提到过。

然后在说说rabbitmq,它提供了一种叫镜像集群模式,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。

RabbitMQ 有很好的管理控制台,可以在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。只不过消息需要同步到所有机器上,导致网络带宽压力和消耗很重。

最后再说说kafka,它是天然的分布式消息队列,在Kafka 0.8 以后,提供了副本机制,一个 topic要求指定partition数量,每个 partition的数据都会同步到其它机器上,形成自己的多个 replica 副本,所有 replica 会选举一个 leader 出来,其他 replica 就是 follower。写的时候,leader 会负责把数据同步到所有 follower 上去。如果某个 broker 宕机了,没事儿,那个 broker上面的 partition 在其他机器上都有副本的,如果这上面有某个 partition 的 leader,那么此时会从 follower 中重新选举一个新的 leader 出来。

🌟 传统云部署伸缩模式

🍊 创建专用网络VPC

在这里插入图片描述

🍊 安全组

在这里插入图片描述

🍊 创建云服务器

在这里插入图片描述在这里插入图片描述在这里插入图片描述

🍊 打包部署

在这里插入图片描述

🍊 2. Java环境

# 🌟下载jdk17
wget https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz
# 🌟安装上传工具 以后使用命令 rz  选中文件进行上传
yum install -y lrzsz

# 🌟解压
tar -xzvf jdk-17_linux-x64_bin.tar.gz

# 🌟移动到指定位置; 记住全路径: /opt/user/jdk-17.0.8
mv jdk-17.0.8 /opt/user/jdk-17.0.8

# 🌟配置环境变量   /opt/jdk-17.0.2
vim /etc/profile

# 🌟在最后加入下面配置,注意修改 JAVA_HOME位置为你自己的位置
export JAVA_HOME=/opt/user/jdk-17.0.8
export PATH=$JAVA_HOME/bin:$PATH

# 🌟使环境变量生效
source /etc/profile

# 🌟验证安装成功
java -version

在这里插入图片描述

🍊 启动项目

在这里插入图片描述测试能否访问
在这里插入图片描述

🍊 开机启动任意服务

作为基础服务器,需要配置开机自启服务,方便后面自动伸缩以这台服务器为主,扩容服务器能实现开机运行java服务。

🎉 1. 制作服务文件

cd /usr/lib/systemd/system
vim springbootapp.service
# 🌟内容如下

[Unit]
Description=springbootapp
After=syslog.target network.target remote-fs.target nss-lookup.target

[Service]
Type=forking
ExecStart=/opt/app/app-start.sh
ExecStop=/opt/app/app-stop.sh
PrivateTmp=true

[Install]
WantedBy=multi-user.target

🎉 2. 制作启动脚本

vim app-start.sh

内容如下

# 🌟!/bin/sh
export JAVA_HOME=/opt/jdk-17.0.2
export PATH=$JAVA_HOME/bin:$PATH
nohup java -Xms2048m -Xmx2048m -Xss1m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -jar /opt/app/app.jar > /opt/app/app.log 2>&1 & --spring.profiles.active=prod
echo $! > /opt/app/app-service.pid

🎉 3. 制作停止脚本

vim app-stop.sh

内容如下

# 🌟!/bin/sh
PID=$(cat /opt/app/app-service.pid)
kill -9 $PID

🎉 4. 增加执行权限

chmod +x app-start.sh
chmod +x app-stop.sh

🎉 5. 设置开机启动

systemctl daemon-reload
systemctl status springbootapp
systemctl enable springbootapp

关闭开机自启动

systemctl disable springbootapp

立即执行启动服务脚本

systemctl start springbootapp

立即执行关闭服务脚本

systemctl stop springbootapp

🍊 创建镜像

在这里插入图片描述在这里插入图片描述

🍊 继续创建多台云服务器

在这里插入图片描述在这里插入图片描述在这里插入图片描述
在这里插入图片描述

🍊 负载均衡

在这里插入图片描述
在这里插入图片描述直接通过负载均衡的ip访问服务的8888端口
在这里插入图片描述测试一下
在这里插入图片描述
在这里插入图片描述在这里插入图片描述在这里插入图片描述

🍊 弹性伸缩

在这里插入图片描述在这里插入图片描述

在这里插入图片描述

在这里插入图片描述在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述如果我配置的期望值是1台服务器,那么过一段时间没有负载就会直接把你创建的实例给删除了,仅保留一台。
在这里插入图片描述
在这里插入图片描述

🍊 redis的报警规则

在这里插入图片描述在这里插入图片描述
在这里插入图片描述
在这里插入图片描述在这里插入图片描述

🍊 白名单

这里设置和云服务器的一样,可以在同一个网络访问,其他ip即便知道用户名密码和主机ip也无法访问。
在这里插入图片描述

🌟 秒级部署模式

🍊 服务器秒级部署

阿里云有ACK,可参考文档:通过ACK控制台快速搭建魔方游戏
华为云有云容器引擎 CCE,可参考文档:https://www.huaweicloud.com/product/cce.html

🍊 应用服务快速部署

应用服务目前市面上没有任何产品可以做到秒级部署,通常情况下都是对其进行优化,优化方式有以下几种:

🎉 延迟初始化Bean

springboot2.2版本引入spring.main.lazy-initialization属性,配置为true会将所有Bean延迟初始化。

spring.main.lazy-initialization=true

通常情况下可以节约个1到2秒时间,当然根据实际情况不同,优化的速度也不一样。

🎉 创建扫描索引

Spring5之后提供了spring-context-indexer功能,可以通过在编译时创建一个静态候选列表来提高大型应用程序的启动性能。在项目中使用@Indexed之后,编译打包的时候会在项目中自动生成META-INT/spring.components文件。当Spring应用上下文执行ComponentScan扫描时,META-INT/spring.components将会被CandidateComponentsIndexLoader读取并且加载,转化为CandidateComponentsIndex对象。
首先需要在pom.xml文件中添加以下依赖:

<dependency>
	<groupId>org.springframework</groupId>
	<artifactId>spring-context-indexer</artifactId>
	<optional>true</optional>
</dependency>

然后在Spring Boot的应用程序类中,可以使用@Indexed注解来标记要进行索引的组件,并在编译时创建静态候选列表。

示例代码:

@SpringBootApplication
@Indexed
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

在这个示例中,@Indexed注解被用于标记了MyApplication类,表示这个类需要被索引。SpringBootPlugin插件会自动将这些类组件添加到生成的SpringBoot应用程序的索引中。
Indexed

最后,在应用程序启动时,Spring框架会使用这个索引来加速组件的扫描和加载,提高应用程序的启动性能。需要注意的是,这个功能只适用于使用Spring Boot构建的应用程序。

通常情况下可以节约个1到2秒时间,当然根据实际情况不同,优化的速度也不一样。

🍊 其他优化方案

1、减少@ComponentScan @SpringBootApplication扫描类时候的范围
2、关闭 Spring Boot 的JMX监控,设置 spring.jmx.enabled=false
3、设置JVM参数-noverify,不对类进行验证
4、对非必要启动时加载的Bean,延迟加载
5、使用Spring Boot的全局懒加载
6、AOPQ切面尽量不使用注解方式,这会导致启动时扫描全部方法
7、关闭endpoint的一些监控功能
8、排除项目多余的依赖jar,这个需要谨慎排查
9、swagger扫描接口时,指定只扫描某个路径下的类
10、 Feign 客户端接口的扫描缩小包扫描范围
11、拆分服务,让服务轻量化,最极致的就是手写一个springboot框架,仅把需要的依赖引入,同时将可以异步的业务拆分出去,例如使用mq解耦,接口接收请求,处理同步业务之后,其他能异步的,通过线程池发送mq消息,消费端就不放到这个项目里面,拆分到其他服务里面,实现分布式服务,达到最终启动时间也就几秒左右的时间。

🎉 升级JDK的版本

高版本的jdk做了很多优化,能提升服务启动的速度,例如jdk21版本优化了虚拟线程提升并发、新的垃圾回收器更强劲,更好的算法、更高效的工具或更新的类库版本,都有助于提高应用程序的性能和启动速度。但是这个需要根据实际情况进行取舍,大部分老的项目都会使用jdk8版本,升级高版本的需要结合实际情况进行衡量。

🎉 通过将应用程序打包成WAR文件并部署到已经启动的Tomcat服务器上

这种方式可以使用Spring Boot的maven插件将应用程序打包成WAR文件,并将WAR文件部署到Tomcat服务器中。这样做可以节省启动服务器的时间,但仍然需要一定的时间来完成新应用程序的部署和启动。当你将WAR文件部署到Tomcat服务器上时,Tomcat会自动将该WAR文件解压缩到一个文件夹中,并在服务器上自动创建一个应用程序上下文。应用程序上下文是指虚拟Web应用程序的目录,其中存放了Web应用程序的资源文件,包括JSP文件、HTML文件、Servlet类文件等。一旦应用程序上下文被创建,Tomcat就会将该应用程序注册到Servlet容器中,以便于处理客户端请求。客户端可以通过Web浏览器访问应用程序上下文,访问应用程序中的不同资源。因此,当WAR文件部署到Tomcat服务器上时,Tomcat会自动将应用程序注册到Servlet容器中,并开始处理客户端发来的请求,从而实现对应用程序接口的访问。
使用Spring Boot的maven插件将应用程序打包成WAR文件非常简单,只需要在pom.xml文件中添加以下配置:

<packaging>war</packaging>
<build>
	<plugins>
		<plugin>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-maven-plugin</artifactId>
			<version>2.5.2</version>
			<executions>
				<execution>
					<goals>
						<goal>repackage</goal>
					</goals>
				</execution>
			</executions>
		</plugin>
	</plugins>
</build>

这种方式缺点非常明显,首先不能满足弹性伸缩的要求,一台服务器刚启动想跑自己的应用服务,还得将自己部署到已启动的tomcat下,这就很扯了,但是这种方案在某些特定场景下倒是有一定的用途。

🎉 将Spring Boot应用程序转换为无服务器函数

Spring Cloud Function是可以部署在云平台上的无状态函数式服务框架,可以将Spring Boot应用程序转换为无服务器函数。这种方式可以使用AWS Lambda或Google Cloud Functions等云原生平台。Spring Cloud Function是一款基于Spring Framework构建的,支持编写无服务器Lambda函数的工具,使用它可以让开发人员轻松地将Spring应用程序转换为无服务器Lambda函数。

将Spring Boot应用程序转换为无服务器函数可以提高应用程序的启动速度。以下是原因:

  1. 冷启动时间缩短:在传统服务器中,应用程序需要等待服务器启动并运行,然后才能响应请求。但是无服务器将应用程序分解为小型函数,并在请求到达时动态启动它们。因此,无服务器应用程序的启动时间非常短,可以提供更快的体验。

  2. 更少的资源消耗:在使用传统服务器时,应用程序可能会占用服务器资源,并在不必要的时候消耗大量资源。但是,在无服务器环境中,只有在请求到达时才会启动运行代码,因此可以减少资源消耗和成本。

  3. 更高的可伸缩性:无服务器环境可以动态地增加或减少函数实例的数量,以适应请求量的变化。这种可伸缩性可以提供更高的性能和可用性。

这种方案缺点也很明显,在高并发场景下,将应用程序部署为无服务器函数也可能存在一些问题。首先,由于无服务器函数是按需启动的,因此可能会在处理高并发请求时出现延迟,因为函数需要在请求到达时才会启动。其次,函数运行时间有可能会超过无服务器平台的限制,导致函数中断或重试。此外,由于无服务器函数通常是无状态的,因此可能需要重新设计应用程序以便无状态运行。最后,由于无服务器函数需要与云平台集成,因此需要额外的配置和管理工作。

🎉 使用AWS Lambda、Azure Functions、Google Cloud Functions等云服务提供商来托管应用程序

AWS Lambda、Azure Functions、Google Cloud Functions等云服务提供商提供了一种快速托管应用程序的方法。这些服务可以自动管理服务器和基础架构,而且还提供了很多高级功能,例如自动扩展、自动备份、自动恢复等等。使用这些云服务提供商可以大大加快应用程序的启动速度和运行效率。AWS Lambda、Azure Functions、Google Cloud Functions 都是云服务提供商,提供了一种快速托管应用程序的方法。它们可以自动管理服务器和基础架构,提供了很多高级功能,例如自动扩展、自动备份、自动恢复等等。

使用云服务提供商来托管新的应用程序可以加快应用程序的启动速度的原因如下:

  1. 操作系统预热:当应用程序运行在云服务提供商的环境中时,操作系统已经预热,因此不需要等待多个组件启动和初始化。这样,应用程序启动速度会更快。

  2. 资源的有效分配:云服务提供商可以动态分配资源来满足应用程序的需求。这意味着,应用程序可以获得更多的资源,从而加快启动时间和处理时间。

  3. 自动扩容:一些云服务提供商可以自动扩容来满足应用程序的需求。这意味着,当应用程序需要更多的资源时,云服务提供商可以自动提供更多的资源,从而加快启动时间和处理时间。

  4. 优化的网络:云服务提供商通常具有优化的网络基础设施,这可以减少网络延迟和带宽瓶颈,从而加快应用程序的启动速度和运行效率。

使用云服务提供商来托管新的应用程序可能会面临以下风险:

  1. 安全风险:由于应用程序的部署和运行都在云中,因此安全风险可能会增加。攻击者可能会利用漏洞或错误的配置方式攻击应用程序或获得对云服务提供商的访问权限。

  2. 数据隐私问题:由于应用程序处理敏感数据,并存储在云中,因此可能会对用户的数据隐私产生潜在的威胁。此外,应用程序在云中运行时,也可能会访问敏感数据,因此需要谨慎保护数据隐私。

  3. 依赖问题:使用云服务提供商托管应用程序意味着应用程序依赖于云服务提供商的基础设施和服务。如果云服务提供商的服务出现故障或中断,应用程序也可能会受到影响。

  4. 单一供应商锁定:使用云服务提供商可能会导致单一供应商锁定,这意味着应用程序完全依赖于一个云服务提供商。如果服务提供商出现问题或者需要更改服务提供商,将会带来一些挑战和成本。

  5. 成本问题:虽然云服务提供商在一定程度上可以降低成本,但在一些情况下,成本也有可能会增加。例如,在实现高可用性或多区域部署时,成本可能会成倍增加。

📢总结

对本篇文章进行总结:

🔔以上就是今天要讲的内容,阅读结束后,反思和总结所学内容,并尝试应用到现实中,有助于深化理解和应用知识。与朋友或同事分享所读内容,讨论细节并获得反馈,也有助于加深对知识的理解和吸收。

🔔如果您需要转载或者搬运这篇文章的话,非常欢迎您私信我哦~

🚀🎉希望各位读者大大多多支持用心写文章的博主,现在时代变了,🚀🎉🌟 信息爆炸,酒香也怕巷子深🔥,博主真的需要大家的帮助才能在这片海洋中继续发光发热🎨,所以,🏃💨赶紧动动你的小手,点波关注❤️,点波赞👍,点波收藏⭐,甚至点波评论✍️,都是对博主最好的支持和鼓励!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java程序员廖志伟

赏我包辣条呗

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值