背景

TiDB 集群的监控面板里面有两个非常重要、且非常常用的指标,相信用了 TiDB 的都见过:

  • Storage capacity:集群的总容量
  • Current storage size:集群当前已经使用的空间大小

当你准备了一堆服务器,经过各种思考设计部署了一个 TiDB 集群,有没有想过这两个指标和服务器磁盘之间到底是啥关系?

反正我们经常被客户问这个问题,以前虽然能说出个大概,总体方向上没错,但是深究一下其实并不严谨,这次翻了源码彻底把这个问题搞清楚。开始之前再卖一个关子,大家可以看看自己手上的集群监控有没有这种情况:

TiKV 实例的已用空间(store size)+ 可用空间(available size) ≠ 总空间(capacity size)

盘越大越明显。

再仔细点看,监控上显示的总容量大小和 TiKV 实例所在盘大小也不匹配。

是不是有亿点意外。

结论先行

  • PD 监控下的Storage capacityCurrent storage size来自各个 store 的累加,这里 store 包含了 TiKV 和 TiFlash
  • Current storage size包含了多个数据副本(TiKV 和 TiFlash 的所有副本数),非真实数据大小
  • TiKV 实例容量统计的是 TiKV 所在磁盘的整体大小与raftstore.capacity参数较小的值,同时监控用的 bytes(SI) 标准显示,就是说不是用1024做的转换而是1000,所以和df -h输出的盘大小有差距
  • TiKV 实例的已用空间只统计了data-dir下的部分目录,非整个data-dir或整块盘
  • 基于前两条,可用空间也就不等于总空间减去已用空间了

看到的现象

本文描述的内容基于以下集群:

[tidb@localhost ~]$ tiup cluster display tidb-test
tiup is checking updates for component cluster ...
Starting component `cluster`: /home/tidb/.tiup/components/cluster/v1.13.0/tiup-cluster display tidb-test
Cluster type:       tidb
Cluster name:       tidb-test
Cluster version:   v6.5.5
Deploy user:       tidb
SSH type:           builtin
Dashboard URL:     http://x.236:2379/dashboard
Grafana URL:       http://x.242:3000
ID         Role       Host   Ports       OS/Arch       Status   Data Dir     Deploy Dir
--         ----       ----   -----       -------       ------   --------     ----------
x.242:3000   grafana     x.242 3000         linux/x86_64 Up       -           /data/tidb-deploy/grafana-3000
x.235:2379   pd         x.235 2379/2380   linux/x86_64 Up       /data/tidb-data/pd-2379               /data/tidb-deploy/pd-2379
x.236:2379   pd         x.236 2379/2380   linux/x86_64 Up|L|UI /data/tidb-data/pd-2379               /data/tidb-deploy/pd-2379
x.237:2379   pd         x.237 2379/2380   linux/x86_64 Up       /data/tidb-data/pd-2379               /data/tidb-deploy/pd-2379
x.242:9090   prometheus x.242 9090/12020   linux/x86_64 Up       /data/tidb-data/prometheus-9090       /data/tidb-deploy/prometheus-9090
x.235:4000   tidb       x.235 4000/10080   linux/x86_64 Up       -            /data/tidb-deploy/tidb-4000
x.236:4000   tidb       x.236 4000/10080   linux/x86_64 Up       -             /data/tidb-deploy/tidb-4000
x.237:4000   tidb       x.237 4000/10080   linux/x86_64 Up       -             /data/tidb-deploy/tidb-4000
x.241:9000   tiflash     x.241 9000/8123/3930/20170/20292/8234 linux/x86_64 Up /data/tiflash/tidb-data/tiflash-9000 /data/tiflash/tidb-deploy/tiflash-9000
x.238:20160 tikv       x.238 20160/20180 linux/x86_64 Up       /data/tidb-data/tikv-20160           /data/tidb-deploy/tikv-20160
x.239:20160 tikv       x.239 20160/20180 linux/x86_64 Up       /data/tidb-data/tikv-20160           /data/tidb-deploy/tikv-20160
x.240:20160 tikv       x.240 20160/20180 linux/x86_64 Up       /data/tidb-data/tikv-20160           /data/tidb-deploy/tikv-20160

各节点磁盘情况(来自TiDB Dashboard统计):

在此之前,我一直以为 PD 监控面板下的集群总空间是 PD 读取了所有 TiKV+TiFlash 实例部署盘的累计大小,所以我尝试把上图的4个存储节点的磁盘容量相加发现并不等于集群总容量(文章开头的图片有显示),差了100多个G:

Dashboard上4个存储节点磁盘容量均为475.8G,累计容量475.8G * 4 = 1903.2G

Grafana显示的单个 TiKV 实例:510.9G,总空间:2.04 T

再和操作系统显示的磁盘容量对比,发现能和 Dashboard 显示的对应上:

[tidb@localhost ~]$ df -h
Filesystem               Size Used Avail Use% Mounted on
/dev/mapper/centos-root 476G 347G 110G 77% /
devtmpfs                 31G     0   31G   0% /dev
tmpfs                     31G 4.0K   31G   1% /dev/shm
tmpfs                     31G 3.1G   28G 10% /run
tmpfs                     31G     0   31G   0% /sys/fs/cgroup
/dev/sda1               477M 138M 310M 31% /boot

但仔细看容量单位的区别,发现 Dashboard 显示的是GiB,Grafana 显示的是GB,两者是有区别的。尝试在系统中用GB显示磁盘大小:

[tidb@localhost ~]$ df -H
Filesystem               Size Used Avail Use% Mounted on
/dev/mapper/centos-root 511G 372G 118G 77% /
devtmpfs                 34G     0   34G   0% /dev
tmpfs                     34G 4.1k   34G   1% /dev/shm
tmpfs                     34G 3.3G   30G 10% /run
tmpfs                     34G     0   34G   0% /sys/fs/cgroup
/dev/sda1               500M 145M 325M 31% /boot

这里输出的511G能和 Grafana 监控对应上,同时按4个511G的存储节点计算也能和总容量对应上。

但是用同样的方法并不能解释 TiKV 已用空间的偏差问题,检查结果如下:

# 输出内容为240节点tikv数据目录大小,但监控显示tikv已用空间333.7GB
[tidb@localhost ~]$ du -sh /data/tidb-data/tikv-20160
329G /data/tidb-data/tikv-20160
[tidb@localhost ~]$ du -sh --si /data/tidb-data/tikv-20160
353G /data/tidb-data/tikv-20160

总结一下看到的现象:

  • Dashboard 上显示的 TiKV 盘大小(GiB)是实际部署盘的总大小,Grafana 也是部署盘的总大小但单位是GB
  • Grafana 集群总容量是所有存储节点部署盘的累计大小(GB)
  • TiKV 实例已用空间大小计算方式未知(要搞清楚只能扒源码了)

不同进制转换带来的影响

这里简单提一下GBGiB的区别,帮助大家理解。

  • GB 是按10进制来转换,也就是说1GB=1000MB,市面上厂商宣传的大小都是10进制,可理解为商业标准
  • GiB 是按2进制来转换,也就是说1GiB=1024MiB,计算机系统只认这个,可理解为事实标准

那么当你买了一台128G存储的手机,实际使用中会发现空间“缩水”了,U盘、硬盘等也类似。

与这两个进制差异有关的还有两个行业标准,即byte(SI)byte(IEC),感兴趣的可以去查一下历史,这里只需要知道:

  • byte(SI)对应十进制
  • byte(IEC)对应二进制

Grafana 里面可以使用编辑监控面板调整显示单位,例如:

如果把单位统一的话,前2个现象就很好解释了。

但需要注意的是,在 Grafana 中并不是所有面板都采用了byte(SI),甚至同一个指标也出现不同面板显示的单位不一样,比如Overview下面的TiDB分组内存面板使用十进制,System Info分组内存面板使用二进制,用的时候要小心。

TiKV 的数据文件

要搞清楚 TiKV 的已用空间是怎么计算的,先提一下 TiKV 相关的数据文件。大家都知道 TiKV 底层用的 RocksDB 作为持久层,并且 raft 日志和实际数据分别对应一个RocksDB 实例,那么看看 TiKV 的数据目录到底放了啥东西,以前面的集群为例:

[tidb@localhost ~]$ cd /data/tidb-data/tikv-20160
[tidb@localhost tikv-20160]$ ll -h
total 14G
drwxr-xr-x 2 tidb tidb 1.1M Dec 22 15:22 db
drwxr-xr-x 4 tidb tidb 132K Dec 21 18:20 import
-rw-r--r-- 1 tidb tidb 20K Nov 15 14:47 last_tikv.toml
-rw-r--r-- 1 tidb tidb   0 Nov 15 14:53 LOCK
-rw-r--r-- 1 tidb tidb 301M Mar 3 2023 raftdb-2023-03-03T17-32-13.506.info
...
-rw-r--r-- 1 tidb tidb 301M Nov 13 21:51 raftdb-2023-11-13T21-51-00.249.info
-rw-r--r-- 1 tidb tidb 31M Nov 15 14:47 raftdb.info
drwxr-xr-x 2 tidb tidb 4.0K Dec 22 00:08 raft-engine
-rw-r--r-- 1 tidb tidb 301M Jan 18 2023 rocksdb-2023-01-18T10-12-55.000.info
...
-rw-r--r-- 1 tidb tidb 301M Dec 7 03:01 rocksdb-2023-12-07T03-01-02.905.info
-rw-r--r-- 1 tidb tidb 197M Dec 22 17:11 rocksdb.info
drwxr-xr-x 2 tidb tidb 4.0K Dec 21 16:27 snap
-rw-r--r-- 1 tidb tidb 4.8G Nov 15 14:53 space_placeholder_file

几类文件解读一下:

  • db 目录,这是最终数据的存放目录,db在源码中写死无法修改
  • rocksdb[-xxx-xxx].info文件,数据 RocksDB 实例的日志文件,已经按日期归档好的可手动删除
  • raft-engine 目录,这是 raft 日志存放目录,受参数raft-engine.dir控制,没有开启Raft Engine特性时名称默认为raft,受参数raftstore.raftdb-path控制
  • raftdb[-xxx-xxx].info文件,raft日志 RocksDB 实例的日志文件,已经按日期归档好的可手动删除
  • snap 目录,快照数据存放目录
  • import 目录,看名字是和导入相关,具体什么作用未知
  • space_placeholder_file 文件,预留空间的临时文件(TiKV磁盘告警救急用,磁盘越大这个文件越大),相关参数storage.reserve-space
  • last_tikv.toml 和 LOCK 文件,看名字猜测就行

从前面的观察来看,被监控统计到的 TiKV 已用空间比整个数据目录要小,那么可以推测出只统计了数据目录下的部分文件或目录,具体是哪些就要从源码里寻找答案。

Show Me The Code

TiDB的监控数据分为两类,一类是服务器环境信息(CPU、内存、磁盘、网络等),一类是TiDB运行指标(Duration、QPS、Region数、容量等)。前者通过与Prometheus配套的标准探针采集,即node_exporterblack_exporter,后者通过在源码中类似埋点方式采集数据然后由Prometheus来拉取。

import (
...
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
...
)

var (
// PanicCounter measures the count of panics.
PanicCounter *prometheus.CounterVec

// MemoryUsage measures the usage gauge of memory.
MemoryUsage *prometheus.GaugeVec
)

以集群总容量这个指标入手,看看在源码中它是如何采集的。对应的公式:

pd_cluster_status{k8s_cluster="$k8s_cluster", tidb_cluster="$tidb_cluster", instance="$instance",type="storage_capacity"}

用关键字storage_capacity去 PD 源码里搜索找到如下代码:

func (s *storeStatistics) Collect() {
placementStatusGauge.Reset()

metrics := make(map[string]float64)
...
metrics["storage_capacity"] = float64(s.StorageCapacity)

for typ, value := range metrics {
clusterStatusGauge.WithLabelValues(typ).Set(value)
}
...
}

数据来自storeStatisticsStorageCapacity字段,根据引用关系继续往上翻:

func (s *storeStatistics) Observe(store *core.StoreInfo) {
...
// Store stats.
s.StorageSize += store.StorageSize()
s.StorageCapacity += store.GetCapacity()
   ...
}

从这里可以看出总容量(Storage capacity)和总已用空间(Current storage size)都是从各个store累加得来,并不是pd直接从存储节点计算。

继续看GetCapacity()是如何实现:

func (ss *storeStats) GetCapacity() uint64 {
ss.mu.RLock()
defer ss.mu.RUnlock()
return ss.rawStats.GetCapacity()
}
func newStoreStats() *storeStats {
return &storeStats{
rawStats:     &pdpb.StoreStats{},
avgAvailable: movingaverage.NewHMA(60), // take 10 minutes sample under 10s heartbeat rate
}
}

这里rawStats 是一个pdpb.StoreStats类型,引用了另一个仓库:https://github.com/pingcap/kvproto 。最终实现为:

// https://github.com/pingcap/kvproto/blob/master/pkg/pdpb/pdpb.pb.go#L4371C1-L4376C2
func (m *StoreStats) GetCapacity() uint64 {
if m != nil {
return m.Capacity
}
return 0
}

从调用关系来看,说明 PD 采集的数据都是来自 TiKV 上报(heartbeat)。继续追踪 TiKV 源码,以heartbeat为突破口:

pub fn handle_store_heartbeat(
      &mut self,
       mut stats: pdpb::StoreStats,
       is_fake_hb: bool,
       store_report: Option<pdpb::StoreReport>,
  ) {
      ...
       let (capacity, used_size, available) = self.collect_engine_size().unwrap_or_default();
       if available == 0 {
           warn!(self.logger, "no available space");
      }

       stats.set_capacity(capacity);
       stats.set_used_size(used_size);
       stats.set_available(available);
      ...
}

这里的stats正是一个pdpb::StoreStats类型,我们想要分析的3个指标都在这,继续看他们的出处collect_engine_size():

fn collect_engine_size<EK: KvEngine, ER: RaftEngine>(
   coprocessor_host: &CoprocessorHost<EK>,
   store_info: Option<&StoreInfo<EK, ER>>,
   snap_mgr_size: u64,
) -> Option<(u64, u64, u64)> {
   if let Some(engine_size) = coprocessor_host.on_compute_engine_size() {
       return Some((engine_size.capacity, engine_size.used, engine_size.avail));
  }
   let store_info = store_info.unwrap();
   // 这里跟根据kv engine的目录(${data_dir}/db)获取了所在磁盘的信息
   let disk_stats = match fs2::statvfs(store_info.kv_engine.path()) {
       Err(e) => {
           error!(
               "get disk stat for rocksdb failed";
               "engine_path" => store_info.kv_engine.path(),
               "err" => ?e
          );
           return None;
      }
       Ok(stats) => stats,
  };
   // total_space得到磁盘的总容量,参考API:https://docs.rs/fs2/latest/fs2/fn.total_space.html
   let disk_cap = disk_stats.total_space();
   // 计算得出tikv实例的容量,用磁盘容量与参数设置的容量(raftstore.capacity)相比
   // 如果没有设置raftstore.capacity参数,或者是磁盘容量小于设置的容量,那么取磁盘容量,否则取设置的容量,本质就是取较小的那个
   let capacity = if store_info.capacity == 0 || disk_cap < store_info.capacity {
       disk_cap
  } else {
       store_info.capacity
  };
   // 计算已用大小,快照大小(snap目录) + kv engine大小(db目录) + raft engine大小(raft-engine目录)
   let used_size = snap_mgr_size
       + store_info
          .kv_engine
          .get_engine_used_size()
          .expect("kv engine used size")
       + store_info
          .raft_engine
          .get_engine_size()
          .expect("raft engine used size");
   // 计算逻辑可用空间,总容量-已用空间
   let mut available = capacity.checked_sub(used_size).unwrap_or_default();
   // We only care about rocksdb SST file size, so we should check disk available
   // here.
   // 最终可用空间是取逻辑可用和磁盘可用的较小值
   available = cmp::min(available, disk_stats.available_space());
   Some((capacity, used_size, available))
}

核心逻辑分析都写在注释里,值得认真一看!

以为扒到这里就happy ending了,但是偶然又发现了另一个方法让我陷入沉思:

    fn init_storage_stats_task(&self, engines: Engines<RocksEngine, ER>) {
      ...
       self.background_worker
          .spawn_interval_task(DEFAULT_STORAGE_STATS_INTERVAL, move || {
               let disk_stats = match fs2::statvfs(&store_path) {
                   Err(e) => {
                       error!(
                           "get disk stat for kv store failed";
                           "kv path" => store_path.to_str(),
                           "err" => ?e
                      );
                       return;
                  }
                   Ok(stats) => stats,
              };
               let disk_cap = disk_stats.total_space();
               let snap_size = snap_mgr.get_total_snap_size().unwrap();

               let kv_size = engines
                  .kv
                  .get_engine_used_size()
                  .expect("get kv engine size");

               let raft_size = engines
                  .raft
                  .get_engine_size()
                  .expect("get raft engine size");
              ...
               let placeholer_file_path = PathBuf::from_str(&data_dir)
                  .unwrap()
                  .join(Path::new(file_system::SPACE_PLACEHOLDER_FILE));

               let placeholder_size: u64 =
                   file_system::get_file_size(placeholer_file_path).unwrap_or(0);
               // 这里的已用空间计算方式与heartbeat有区别,把space_placeholder_file文件算进去了,还加了raft engine单独部署的逻辑
               let used_size = if !separated_raft_mount_path {
                   snap_size + kv_size + raft_size + placeholder_size
              } else {
                   snap_size + kv_size + placeholder_size
              };
               let capacity = if config_disk_capacity == 0 || disk_cap < config_disk_capacity {
                   disk_cap
              } else {
                   config_disk_capacity
              };

               let mut available = capacity.checked_sub(used_size).unwrap_or_default();
               available = cmp::min(available, disk_stats.available_space());
              ...
          })
  }

init_storage_stats_task在tikv启动时被调用,就是说这是几个指标在初始化时的计算方式,整体逻辑与heartbeat上报并无区别,但已用空间计算方式有轻微差异:

  • space_placeholder_file 被算进去了
  • 如果raft engine使用了单独的部署目录(代码里叫path_in_diff_mount_point),那么raft日志的大小是不算在tikv已用空间内的

看起来前后计算不一致,但是由于heartbeat是持续更新的,最终是以heartbeat上报的为准。

这个差异准备提issue问问看。

PD 源码仓库:https://github.com/tikv/pd

TiKV 源码仓库:https://github.com/tikv/tikv

结尾

结论已经在文章开头给出了,希望大家看了本文都能对TiDB集群的各种空间计算有了清晰的认识。

本文只讨论的 TiKV 的容量计算细节,TiFlash 的计算方式也类似,我对比了 TFlash 的数据目录大小和监控显示已用大小10多G的差距,应该也是只计算了部分目录,但是总容量还是算的整块盘。不太熟悉 c++,留给其他大佬去探索吧。

作者介绍:hey-hoho,来自神州数码钛合金战队,是一支致力于为企业提供分布式数据库TiDB整体解决方案的专业技术团队。团队成员拥有丰富的数据库从业背景,全部拥有TiDB高级资格证书,并活跃于TiDB开源社区,是官方认证合作伙伴。目前已为10+客户提供了专业的TiDB交付服务,涵盖金融、证券、物流、电力、政府、零售等重点行业。

一篇文章彻底搞懂TiDB集群各种容量计算方式的更多相关文章

  1. 搞懂 ZooKeeper 集群的数据同步

    本文作者:HelloGitHub-老荀 Hi,这里是 HelloGitHub 推出的 HelloZooKeeper 系列,免费开源.有趣.入门级的 ZooKeeper 教程,面向有编程基础的新手. 项 ...

  2. 一文轻松搞懂redis集群原理及搭建与使用

    今天早上由于zookeeper和redis集群不在同一虚拟机导致出了点很小错误(人为),所以这里总结一下redis集群的搭建以便日后所需同时也希望能对你有所帮助. 笔主这里使用的是Centos7.如果 ...

  3. 一篇文章彻底搞懂snowflake算法及百度美团的最佳实践

    写在前面的话 一提到分布式ID自动生成方案,大家肯定都非常熟悉,并且立即能说出自家拿手的几种方案,确实,ID作为系统数据的重要标识,重要性不言而喻,而各种方案也是历经多代优化,请允许我用这个视角对分布 ...

  4. 一篇文章彻底搞懂base64编码原理

    开始 在互联网中的每一刻,你可能都在享受着Base64带来的便捷,但对于Base64的基础原理又了解多少?今天这篇文章带领大家了解一下Base64的底层实现. base64是什么东东呢? Base64 ...

  5. Redis | 一文轻松搞懂redis集群原理及搭建与使用

    转载:https://juejin.im/post/5ad54d76f265da23970759d3 作者:SnailClimb 这里总结一下redis集群的搭建以便日后所需同时也希望能对你有所帮助. ...

  6. 一篇文章彻底搞懂异步,同步,setTimeout,Promise,async

    之前翻看别的大佬的博客看到了关于setTimeout,promise还有async执行顺序的文章.观看了几篇之后还是没有怎么看懂,于是自己开始分析代码,并整理了此文章,我相信通过此文章朋友们能对异步同 ...

  7. 一篇文章彻底搞懂Java的大Class到底是什么

    作者在之前工作中,面试过很多求职者,发现有很多面试者对Java的 Class 搞不明白,理解的不到位,一知半解,一到用的时候,就不太会用. 因为自己本身以前刚学安卓的时候,甚至做安卓2,3年后,也是对 ...

  8. 一篇文章彻底搞懂es6 Promise

    前言 Promise,用于解决回调地狱带来的问题,将异步操作以同步的操作编程表达出来,避免了层层嵌套的回调函数. 既然是用来解决回调地狱的问题,那首先来看下什么是回调地狱 var sayhello = ...

  9. 一篇文章快速搞懂Redis的慢查询分析

    什么是慢查询? 慢查询,顾名思义就是比较慢的查询,但是究竟是哪里慢呢?首先,我们了解一下Redis命令执行的整个过程: 发送命令 命令排队 命令执行 返回结果 在慢查询的定义中,统计比较慢的时间段指的 ...

  10. 一篇文章快速搞懂 Atomic(原子整数/CAS/ABA/原子引用/原子数组/LongAdder)

    前言 相信大部分开发人员,或多或少都看过或写过并发编程的代码.并发关键字除了Synchronized,还有另一大分支Atomic.如果大家没听过没用过先看基础篇,如果听过用过,请滑至底部看进阶篇,深入 ...

随机推荐

  1. 基于 Python 和 Vue 的在线评测系统

    基于 Docker,真正一键部署 前后端分离,模块化编程,微服务 ACM/OI 两种比赛模式.实时/非实时评判 任意选择 丰富的可视化图表,一图胜千言 支持 Template Problem,可以添加 ...

  2. Jellyfin Documentation

    Skip to main content     Introduction On this page Welcome to the Jellyfin Documentation Jellyfin is ...

  3. 报错Intel MKL FATAL ERROR: Cannot load libmkl_core.so.的一种解决方法

    问题 今天上80服务器跑mdistiller的代码时,意外发现torch.numpy都不能用了T_T 以torch为例,出现如下报错情况 以numpy为例,出现如下报错情况 我们先看看报错信息,这个报 ...

  4. 一些对dp突然的理解

    突然想到了一些理解,感觉有些模糊,怕忘记,就赶紧记下来就是对于状态的设计 用01背包举例子吧,我们设计状态的时候一定是要保证所有可能在最后优秀的子状态在前面的时候是能够保留下来的也就是我们的状态设计要 ...

  5. Chiplet解决芯片技术发展瓶颈

    这是IC男奋斗史的第38篇原创 本文1776字,预计阅读4分钟. Chiplet封装是什么 介绍Chiplet前,先说下SOC.Chiplet和SOC是两个相互对立的概念,刚好可以用来互为参照. SO ...

  6. undefined reference to vtable for问题解决(QT)

    主要在运行时出现 原因是在自定义类使用信号与槽,在创建文件时,未继承QObject类并且没有添加Q_OBJECT: 解决: 在需要的类中,添加Q_OBJECT,继承QObject类. 然后使用QTCr ...

  7. vscode 切换分支时报错:The following untracked working tree files would be overwritten .....

    执行命令:git clean -d -fx   表示删除 一些 没有 git add 的 文件: git clean 参数 -n 显示将要删除的文件和目录: -x -----删除忽略文件已经对git来 ...

  8. 数论笔记(Full Version)

    数论笔记(Full Version) 一.数论基础: 1.整除: 重新定义除法: 对于计算式:\(a\div b\) 来说,其结果可以变化为以下的式子:$$a = b\lfloor \frac{a}{ ...

  9. vue-router重写push方法,解决相同路径跳转报错,解决点击菜单栏打开外部链接

    修改vue-router的配置文件,默认位置router/index.js import Vue from 'vue' import Router from 'vue-router' /** * 重写 ...

  10. Springboot 自动发送邮件

    完成Springboot配置发件邮箱,自动给其他邮箱发送邮件功能 一.创建springboot基础项目,引入依赖 <!-- Spring Boot 邮件依赖 --> <depende ...