-
目录- 1 软件介绍 - 2 环境配置 - 3 系统配置 - 3.1 关闭防火墙(可选) - 3.2 修改SELINUX为disabled(可选) - 3.3 配置远程yum源(可选) - 4 软件编译 - 4.1 unixODBC Yum源安装 - 4.2 unixODBC源码安装 - 5 软件运行 - 5.1 使用unixODBC连接MySQL - 6 FAQ 1 软件介绍unixODBC是一个来连接数据库的组件。ODBC是Open Database Connect 即开发数据库互连的简称,它是一个用于访问数据库的统一界面标准。ODBC引入一个公共接口以解决不同数据库潜在的不一致性,从而很好的保证了基于数据库系统的应用程序的相对独立性。 2 环境配置硬件平台服务器TaiShan 200 2280处理器 2*KunPeng 920 4826内存16*32G 2666MHz系统盘1 * 1.2T SATA HDD数据盘1 * 960G SSD网络1 * GE(板载) 软件平台软件名称版本号安装方法备注CentOS7.6https://support.huawei.com/enterprise/zh/doc/EDOC1100088654/3e971c8d本文档安装过程选择的环境为“Server with GUI”,并附加了“Development Tools”。gcc4.8.5见4.1章节 3 系统配置3.1 关闭防火墙(可选)# 步骤 1 停止防火墙。 systemctl stop firewalld.service # 步骤 2 关闭防火墙。 systemctl disable firewalld.service3.2 修改SELINUX为disabled(可选)# 步骤 1 关闭防火墙。 sed -i 's/SELINUX=enforcing/SELINUX=disabled/g' /etc/sysconfig/selinux3.3 配置远程yum源(可选)若环境有外网条件,可不用配置本地源,直接用系统配置好的网上源或者自己添加网上源即可。# 步骤 1 备份配置文件 cp -a /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak # 步骤 2 配置华为源 wget -O /etc/yum.repos.d/CentOS-Base.repo https://repo.huaweicloud.com/repository/conf/CentOS-AltArch-7.repo # 步骤 3 清除Yum缓存 yum clean all # 步骤 4 重建缓存 yum makecache 4 软件编译4.1 unixODBC Yum源安装yum源已有unixODBC的2.3.1版本,可以直接通过yum源安装,也可以通过源码编译安装,本章节介绍yum源安装的方式,如需通过源码安装,请见4.2章节。# 步骤 1 yum安装。 yum install unixodbc unixodbc-devel # 步骤 2 查看版本和配置 odbcinst -j4.2 unixODBC源码安装# 步骤 1 安装依赖。 yum install –y gcc gcc-c++ # 步骤 2 下载源码并解压。 wget http://www.unixodbc.org/unixODBC-2.3.7.tar.gz tar -zxvf unixODBC-2.3.7.tar.gz cd unixODBC-2.3.7 # 步骤 3 执行配置。 ./configure# 步骤 4 执行编译安装。 # -j后的参数请根据机器的具体核数填写,充分发挥多核性能。 make -j96 && make install# 步骤 5 验证版本。 odbcinst -j5 软件运行5.1 使用unixODBC连接MySQL# 步骤 1 安装MySQL # 参考《数据库(mysql)环境搭建指导书.pdf》安装MySQL # 步骤 2 安装mysql-connector-odbc yum install –y mysql-connector-odbc # 步骤 3 查看unixODBC配置文件读取位置 odbcinst -j其中odbcinst.ini是驱动信息,odbc.ini是数据库连接信息# 步骤 4 配置驱动信息 # 这里修改的文件请根据步骤3查询到的读取位置修改 #本例为MySQL的驱动信息 vi /etc/odbcinst.ini [MySQL] # 驱动名称,配置连接时需要使用 Description = ODBC for MySQL Driver = /usr/lib/libmyodbc5.so Setup = /usr/lib/libodbcmyS.so Driver64 = /usr/lib64/libmyodbc5.so Setup64 = /usr/lib64/libodbcmyS.so FileUsage = 1# 步骤 5 配置数据库连接信息 vi /etc/odbc.ini [mysql] # 连接名称 Description = The Database for mysql Trace = On TraceFile = stderr Driver = MySQL # 驱动名称,对应驱动配置中的信息 SERVER = 127.0.0.1 # 连接数据库IP/Host USER = root # 连接数据库角色 PASSWORD = 123456 # 数据库连接密码 PORT = 3306 # 数据库连接端口 DATABASE = test1 # 要连接的数据库名称 charset = UTF8 option = 3# 步骤 6 使用unixODBC连接 isql mysql6 FAQERROR: Could not SQLConnect配置完使用isql连接的时候出现报错,但是没有具体的报错信息解决方法:调用时加上-v参数,显示打印报错信息,如下
-
目录- 1 软件介绍 - 2 环境配置 - 3 系统配置 - 3.1 关闭防火墙(可选) - 3.2 修改SELINUX为disabled(可选) - 3.3 配置本地yum源(可选 - 4 软件编译 - 4.1 安装依赖包 - 4.2 MySQL编译安装 - 4.3 MyCAT安装 - 5 软件配置 - 5.1 配置MySQL - 5.2 配置MyCAT - 6 软件运行 - 6.1 启动MyCAT - 7 FAQ - 8 其他 1 软件介绍MyCat是目前最流行的基于Java语言编写的数据库中间件,是一个实现了MySql协议的服务器,其核心功能是分库分表。配合数据库的主从模式还可以实现读写分离。2 环境配置 硬件平台服务器TaiShan 200 2280处理器 2*KunPeng 920 4826内存16*32G 2666MHz系统盘1 * 1.2T SATA HDD数据盘1 * 960G SSD网络1 * GE(板载) 软件平台软件名称版本号安装方法备注CentOS7.6https://support.huawei.com/enterprise/zh/doc/EDOC1100088654/3e971c8d本文档安装过程选择的环境为“Server with GUI”,并附加了“Development Tools”。OpenJDK1.8.0_252见4.1章节本文档使用yum源安装OpenJDK,如需安装其他版本,参考:https://www.huaweicloud.com/kunpeng/software/openjdk.htmlMySQL5.7.27见4.2章节管理节点数据库对版本无特别要求,本文档以5.7.27为例。MyCat1.6.7.4见4.3章节 3 系统配置3.1 关闭防火墙(可选)# 步骤 1 停止防火墙。 systemctl stop firewalld.service # 步骤 2 关闭防火墙。 systemctl disable firewalld.service3.2 修改SELINUX为disabled(可选)# 步骤 1 关闭防火墙。 sed -i 's/SELINUX=enforcing/SELINUX=disabled/g' /etc/sysconfig/selinux3.3 配置本地yum源(可选)若环境有外网条件,可不用配置本地源,直接用系统配置好的网上源或者自己添加网上源即可。# 步骤 1 配置源文件 mv /etc/yum.repos.d/ /etc/yum.repos.d-bak mkdir /etc/yum.repos.d echo -e "[local]\nname=local\nbaseurl=file:///mnt\ngpgcheck=0\nenabled=1" > /etc/yum.repos.d/local.repo # 步骤 2 执行cat确认上一步操作写入成功,显示如下图 cat /etc/yum.repos.d/local.repo# 步骤 3 挂载源镜像,将系统镜像通过KVM挂载 mount /dev/cdrom /mnt4 软件编译4.1 安装依赖包# 步骤 1 安装OpenJDK。 yum install -y java-1.8.0-openjdk java-1.8.0-openjdk-devel4.2 MySQL编译安装MyCAT依赖MySQL运行,需先安装MySQL数据库,本文档将MySQL和MyCAT安装在同一台服务器,实际安装请根据需要部署。MySQL安装指导书参考:《数据库(mysql)环境搭建指导书.pdf》4.3 MyCAT安装# 步骤 1 下载并解压MyCAT。 # 本文档将MyCAT安装到/opt/tools/installed下,实际安装请根据需要部署。 cd /opt/tools/installed wget https://github.com/MyCATApache/Mycat-Server/releases/download/Mycat-server-1.6.7.4-release/Mycat-server-1.6.7.4-release-20200105164103-linux.tar.gz tar –zxvf Mycat-server-1.6.7.4-release-20200105164103-linux.tar.gz5 软件配置第5,6章节展示MyCAT运行的简单例子,实际部署请以真实需求为准。本例中MyCAT和MySQL的对应关系如下:其中,db01-03三个数据库部署在同一个MySQL5.1 配置MySQL# 步骤 1 创建users # 登录MySQL并执行相关语句 mysql –uroot -p MySql > create database db01; MySql > use db01; MySql > CREATE TABLE `users` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL DEFAULT '', `indate` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; #步骤 2 创建item mysql -uroot –p MySql > create database db02; MySql > use db02; MySql > CREATE TABLE `item` ( `id` int(11) NOT NULL AUTO_INCREMENT, `value` int(11) NOT NULL DEFAULT '0', `indate` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; # 步骤 3 创建item mysql -uroot –p MySql > create database db03; MySql > use db03; MySql > CREATE TABLE `item` ( `id` int(11) NOT NULL AUTO_INCREMENT, `value` int(11) NOT NULL DEFAULT '0', `indate` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;5.2 配置MyCAT本章节涉及的配置文件均在MyCAT目录下,文档将MyCAT安装在/opt/tools/installed下,编辑时请根据实际部署位置处理。# 步骤 1 配置xml # Server.xml配置MyCAT服务启动的相关参数,例如对外服务的端口,管理端口以及连接MyCAT的账号密码信息等,[password]为连接MyCAT的密码信息,请根据实际情况配置。 # 注意“TESTDB”为schema.xml中声明的逻辑数据库的名称,如在schema.xml修改,此文档也需要对应修改。 cd /opt/tools/installed/mycat vim conf/server.xml<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mycat:server SYSTEM "server.dtd"> <mycat:server xmlns:mycat="http://io.mycat/"> <system> <property name="useSqlStat">0</property> <!-- 1为开启实时统计、0为关闭 --> <property name="useGlobleTableCheck">0</property> <!--1为开启全局表一致性检测--> <property name="sequnceHandlerType">1</property> <propertyname="processorBufferPoolType">0</property> <property name="serverPort">8066</property> <!--8066为连接mycat的端口--> <property name="managerPort">9066</property> <!--9066为连接管理地址的端口-> <property name="idleTimeout">300000</property> <property name="bindIp">0.0.0.0</property> <property name="frontWriteQueueSize">4096</property> <property name="processors">32</property> <property name="handleDistributedTransactions">0</property> <property name="useOffHeapForMerge">1</property> <property name="memoryPageSize">1m</property> <property name="spillsFileBufferSize">1k</property> <property name="useStreamOutput">0</property> <property name="systemReserveMemorySize">384m</property> <property name="useZKSwitch">true</property> </system> <!-- 配置连接mycat的账号密码, 和逻辑库名称, 可以设置多个--> <user name="root"> <property name="password">[password]</property> <property name="schemas">TESTDB</property> </user> <user name="user"> <property name="password">[password]</property> <property name="schemas">TESTDB</property> <property name="readOnly">true</property> </user> </mycat:server># 步骤 2 配置xml。 # rule.xml配置分表分库的规则,本例使用了取模算法,配置如下 cd /opt/tools/installed/mycat vim conf/server.xml <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mycat:rule SYSTEM "rule.dtd"> <mycat:rule xmlns:mycat="http://io.mycat/"> <!-- 指定表规则的名称, 以及分片的function--> <tableRule name="mod-long"> <rule> <columns>id</columns> <algorithm>mod-long</algorithm> </rule> </tableRule> <!-- 分片名称为mod-long, 为tableRule标签提供--> <function name="mod-long" class="io.mycat.route.function.PartitionByMod"> <!-- 指定有几个节点,咱们有两个db02和db03,所以写2, 取模就是id%2 --> <property name="count">2</property> <property name="virtualBucketTimes">160</property> </function> </mycat:rule># 步骤 3 配置xml。 # schema.xml配置逻辑数据库/表和物理数据库/表的对应关系,以及采用的分片规则,本例配置如下。这里的[password]为连接物理数据库的密码,请按实际情况填写。 cd /opt/tools/installed/mycat vim conf/schema.xml <?xml version="1.0"?> <!DOCTYPE mycat:schema SYSTEM "schema.dtd"> <mycat:schema xmlns:mycat="http://io.mycat/"> <!-- 声明一个逻辑表, 表名为TESTDB, 和server.xml中的对应 --> <schema name="TESTDB" checkSQLschema="false" sqlMaxLimit="100"> <!-- 指定实际表名为users, 是在node_db01节点上--> <table name="users" primaryKey="id" autoIncrement="true" dataNode="node_db01" /> <!-- 实际表名为item, 在node_db02和node_db03节点上--> <table name="item" primaryKey="id" autoIncrement="true" dataNode="node_db02,node_db03" rule="mod-long" /> </schema> <dataNode name="node_db01" dataHost="dataHost01" database="db01" /> <dataNode name="node_db02" dataHost="dataHost01" database="db02" /> <dataNode name="node_db03" dataHost="dataHost01" database="db03" /> <!-- 声明数据库的连接地址, 名称为dataHost01, 为dataNode节点提供--> <dataHost name="dataHost01" maxCon="1000" minCon="10" balance="0" writeType="0" dbType="mysql" dbDriver="native"> <heartbeat>select user()</heartbeat> <!-- 数据库的连接地址,账号和密码--> <writeHost host="server1" url="127.0.0.1:3306" user="root" password="[password]" /> </dataHost> </mycat:schema># 步骤 4 配置启动脚本。 # 需在启动脚本中指定运行的jre,默认安装在/usr/lib/jvm下并且由于启动脚本含”^M”,实际运行会出错,需进行相关处理。 cd /opt/tools/installed/mycat vi start_nowrap.sh JAVA_HOME=/usr/lib/jvm/jre-1.8.0mv startup_nowrap.sh startup_nowrap.sh.bak cat startup_nowrap.sh.bak | tr –d "\r" > startup_nowrap.sh chmod +x startup_nowrap.sh6 软件运行6.1 启动MyCAT# 步骤 1 启动服务 cd /opt/tools/installed/mycat ./bin/startup_nowrap.sh # 步骤 2 登录查看逻辑库信息 # 这里的8066端口由server.xml定义,请根据实际情况处理 mysql –h127.0.0.1 –P8066 –uroot –p MySql> show databases; MySql> use TESTDB; MySql> show tables;# 步骤 3 插入数据 insert into users(id,name,indate) values(1,'hello',now()); insert into users(id,name,indate) values(2,'world',now()); insert into item(id,value,indate) values(1,100,now()); insert into item(id,value,indate) values(2,100,now()); # 步骤 4 登录物理数据库查看分表结果 mysql –h127.0.0.1 –uroot -p MySql> select * from db01.users; MySql> select * from db02.item; MySql> select * from db03.item;7 FAQlang.OutOfMemoryError启动时MaxDirectMemorySize(堆外内存最大值)设置过小,需修改启动脚本,增大MaxDirectMemorySize,本例中修改为6G,也可根据实际情况修改 8 其他参考文档: http://ic-openlabs.huawei.com/openlab/#/knowledgebasequery?task_id=R5S1584D11604705120200527163453996333121 https://blog.csdn.net/yu342107056/article/details/88326540
-
【活动时间】2022/3/7—3/31【直播时间】2022年3月22日 19:00~20:30【直播主题】GaussDB(for MySQL)关键特性发布和技术解读本次直播GaussDB(for MySQL)将正式发布HTAP混合负载特性,复杂查询效率提升百倍,让企业决策更加快速,准确【嘉宾简介】主讲老师:佳恩,华为云数据库高级产品经理10年技术运维,数据库管理经验,熟悉MySQL,SQL Server等数据库引擎原理,设计,商业运营,目前负责华为云数据库GaussDB产品管理工作【参与方式】 注:以下3个话题均可参与,且盖楼评奖相互独立,每个有效盖楼30层以上开奖活动一: 分享海报或活动帖 ,回帖盖楼送好礼【直播报名入口】点我马上报名,3月22日可以收看直播哟!直播报名后,转发分享下方“活动海报”或“本帖”至朋友圈,+转发语:3月22日19:00, GaussDB(for MySQL) 关键特性发布和技术解读,参与活动就能赢取数10种礼品,一起参与吧!公开可见保持2小时以上,将转发内容的朋友圈截图回复至下方评论区回帖内容:华为云账号+转发截图奖励如下:奖品数量发放规则开发者定制笔记本/GaussDB字母笔数量不限每6层随机抽取一个幸运用户米格系列移动电源2500mA数量不限有效盖楼层数≥100层,按回帖格式回帖的用户,每增加10层,增加一个中奖机会 活动二:看直播,惊喜多多哦 (3月22日 19:00-20:30 ) 参与方式:>>>戳我观看直播<<<,直播期间发送小助手给出的口令,参与直播互动,就有机会参与抽奖奖励规则:登录直播平台观看本次直播,直播间有3轮抽奖:抽奖轮数中奖数量奖品第一轮2定制水杯/公牛插座第二轮2定制字母笔/平装套芯笔记本第三轮2GaussDB(for MySQL)书籍 活动三: 优质话题互动 ——分享学习感受(3月22日观看直播后再参与)回帖内容:结合本次直播内容,基于GaussDB(for MySQL),谈谈自己的认知、所感所想等。(视频制作前,请先联系小助手,1809232786,说明一下录制的内容,小助手可以帮忙把把关,避免跑题或者词不达意)呈现方式:视频,不短于3分钟录制要求:画质清晰,话题内容贴合要求,录制时组织好语言,视频时长3分钟左右【活动奖品&规则】奖品数量发放规则GaussDB(for MySQL)书籍 / 折叠烧水壶 / 荣耀智能体脂称23+若提交的视频被采用,即可获得左侧奖品其他活动开年采购季,8000元见面礼+0门槛抽奖,云数据库包年18元起!【参与有奖】GaussDB(for MySQL) 关键特性发布和技术解读【注意事项】1.获奖结果将在活动结束后7个工作日内进行公示,请报名和留言的伙伴关注社区内容,所有奖品将在活动结束后15个工作日内发放。2.活动奖品颜色随机,且部分奖品数量有限发完将用等值奖品代替;3.为保证活动的公平公正,华为云有权对恶意刷活动资源(“恶意”是指为获取资源而异常注册账号等**活动公平性的行为),利用资源从事违法违规行为的用户收回抽奖及奖励资格。4.本活动最终解释权归华为云所有。
-
>摘要:WAL机制保证只要redo log和binlog保证持久化到磁盘,就能确保MySQL异常重启后,数据可以恢复。本文分享自华为云社区《[MySQL会丢数据吗?](https://bbs.huaweicloud.com/blogs/334516?utm_source=csdn&utm_medium=bbs-ex&utm_campaign=other&utm_content=content)》,作者: JavaEdge 。 在业务高峰期临时提升性能的方法。 WAL机制保证只要redo log和binlog保证持久化到磁盘,就能确保MySQL异常重启后,数据可以恢复。 # binlog的写入机制 事务执行过程中: - 先把日志写到binlog cache - 事务提交时,再把binlog cache写到binlog文件 一个事务的binlog不该被拆开,不论事务多大,也要确保一次性写入。这就涉及**binlog cache的保存**问题。 系统给binlog cache分配了一片内存,每个线程一个,但是共用同一份binlog文件。参数 **binlog_cache_size**控制单个线程内binlog cache所占内存的大小。若超过该参数值,就要暂存到磁盘。 事务提交时,执行器把binlog cache里的完整事务写入binlog,并清空binlog cache。 - binlog写盘状态 TODO 图中的: - write 把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度较快 - fsync 将数据持久化到磁盘。一般认为fsync才占磁盘的IOPS write 和fsync的时机,由参数sync_binlog控制: - sync_binlog=0,每次提交事务都只write,不fsync - sync_binlog=1,每次提交事务都会执行fsync - sync_binlog=N(N>1),每次提交事务都write,但累积N个事务后才fsync 因此,在出现I/O瓶颈的场景,将**sync_binlog**设置成一个较大值,可提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成0,推荐将其设置为100~1000中的某个数值。 但将**sync_binlog**设置为N,对应的风险是:若主机发生异常重启,会丢失最近N个事务的binlog日志。 # redo log的写入机制 接下来,我们再说说redo log的写入机制。 事务在执行过程中,生成的redo log是要先写到redo log buffer的。 - 那redo log buffer的内容,是不是每次生成后都要直接持久化到磁盘呢? 不需要。 若事务执行期间MySQL异常重启,那这部分日志就丢了。由于事务也尚未提交,所以这时日志丢了也没有损失。 - 那事务还没提交时,redo log buffer中的部分日志有没有可能被持久化到磁盘呢? 会有。 这个问题,要从redo log可能存在的三种状态说起。这三种状态,对应的就是图2 中的三个颜色块。 - MySQL redo log存储状态 TODO 三种状态: - 存在redo log buffer 物理上是在MySQL进程内存 - 写到磁盘(write),但还没持久化(fsync) 物理上是在文件系统的page cache - 持久化到磁盘,即hard disk 日志写到redo log buffer很快,wirte到page cache也差不多,但持久化到磁盘就很慢了。 InnoDB提供**innodb_flush_log_at_trx_commit**参数控制redo log的写入策略: - 0,每次事务提交时都只是把redo log留在redo log buffer中 - 1,每次事务提交时都将redo log直接持久化到磁盘 - 2,每次事务提交时都只是把redo log写到page cache InnoDB的一个后台线程,会每隔1s把redo log buffer中的日志,调用write写到文件系统的page cache,然后调用fsync持久化到磁盘。 事务执行中间过程的redo log也是直接写在redo log buffer,这些redo log也会被后台线程一起持久化到磁盘。即一个没有提交的事务的redo log,也可能已经持久化到磁盘。 除了后台线程每s一次的轮询操作,还有两种场景会让一个未提交的事务的redo log写入磁盘: - redo log buffer占用的空间即将达到 innodb_log_buffer_size的一半,后台线程会主动写盘 由于这个事务并未提交,所以这个写盘动作只是write,没有调用fsync,即只留在文件系统的page cache。 - 并行的事务提交时,顺带将该事务的redo log buffer持久化到磁盘 假设一个事务A执行到一半,已经写了一些redo log到buffer,这时另外一个线程的事务B提交,若innodb_flush_log_at_trx_commit是1,则事务B要把redo log buffer里的日志全部持久化到磁盘。这时,就会带上事务A在redo log buffer里的日志一起持久化到磁盘。 两阶段提交的过程,时序上redo log先prepare,再写binlog,最后再把redo log commit。 若把innodb_flush_log_at_trx_commit置1,则redo log在prepare阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于prepare 的redo log,再加上binlog来恢复的。 每s一次的后台轮询刷盘,再加上崩溃恢复,InnoDB就认为redo log在commit时无需fsync,只write到文件系统的page cache就够了。 通常我们说MySQL的“双1”配置,指的就是sync_binlog、innodb_flush_log_at_trx_commit都是1。即一个事务完整提交前,需要等待两次刷盘: - redo log(prepare 阶段) - binlog 那这意味着我从MySQL看到TPS是2w,每秒就会写四万次磁盘。但我用工具测试,磁盘能力也就2w左右,怎么能实现2w TPS? 得用组提交(group commit)来解释了。 # 日志逻辑序列号(log sequence number,LSN) LSN单调递增,对应redo log的写入点。比如写入length长度的redo log, 则LSN+length。 LSN也会写到InnoDB的数据,以确保数据页不会被多次执行重复的redo log。 如图3所示,是三个并发事务(trx1, trx2, trx3)在prepare 阶段,都写完redo log buffer,持久化到磁盘的过程,对应的LSN分别是50、120 和160。 - redo log 组提交 TODO - trx1第一个到达,被选为这组的leader - 等trx1要开始写盘,组里已经有了三个事务,LSN也变成了160 - trx1去写盘时,带的就是LSN=160。所以,等trx1返回时,所有LSN≤160的redo log,都已被持久化到磁盘 - 这时,trx2和trx3就可直接返回 所以,一次组提交里,组员越多,节约磁盘IOPS效果越好。但若只有单线程压测,则只能老老实实地一个事务对应一次持久化操作。 在并发更新场景下,第一个事务写完redo log buffer后,接下来这个fsync越晚调用,组员可能越多,节约IOPS效果越好。 为了让一次fsync带的组员更多,MySQL采取优化:拖时间。 - 两阶段提交  写binlog实际上分成两步: 1. 先把binlog从binlog cache中写到磁盘上的binlog文件 2. 调用fsync持久化 MySQL为了让组提交效果更好,把redo log做fsync的时间拖到了step1后面: - 两阶段提交细化  这样的话,binlog也可以组提交。上图的step4时,若有多个事务的binlog已经写完,也是一起持久化的,这样也能减少IOPS。 一般step3执行很快,所以binlog的write、fsync间隔时间很短,导致能集合到一起持久化的binlog较少,因此binlog的组提交的效果通常不如redo log的效果。 若想提升binlog组提交效果,可设置: - binlog_group_commit_sync_delay参数 延迟多少微秒后才调用fsync - binlog_group_commit_sync_no_delay_count参数 累积多少次以后才调用fsync 这两个条件是或的关系,即只要有一个满足条件就会调用fsync。 这样的话,binlog_group_commit_sync_delay = 0 时,binlog_group_commit_sync_no_delay_count就无效了。 WAL是减少磁盘写,可每次提交事务都要写redo log和binlog,这磁盘的读写次数也没变少呀?s所以现在就能理解了,WAL主要得益于: - redo log 和 binlog都是顺序写,磁盘的顺序写比随机写速度要快 - 组提交机制,可大幅度降低磁盘IOPS 所以,若MySQL出现IO性能瓶颈,可通过如下方法优化: - 设置 binlog_group_commit_sync_delay 、binlog_group_commit_sync_no_delay_count,减少binlog写盘次数 该方案是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但不会丢数据 - 将sync_binlog 设为大于1的值(推荐100~1000) 风险是,主机掉电时会丢binlog日志。 - 将innodb_flush_log_at_trx_commit设为2 风险是,主机掉电的时候会丢数据。 不推荐把**innodb_flush_log_at_trx_commit** 设成0。因为此时表示redo log只保存在内存,这样MySQL本身异常重启也会丢数据,风险太大。而redo log写到文件系统的page cache的速度是很快的,所以将该参数设成2跟设成0性能差不多,但这样做MySQL异常重启时就不会丢数据了。 # 小结 MySQL是“怎么保证redo log和binlog是完整的”。 crash-safe - 执行一个update后,再执行hexdump直接查看ibd文件内容,为什么没有看到数据有改变? 可能因为WAL。update语句执行完后,InnoDB只保证写完了redo log、内存,可能还没来得及将数据写磁盘。 - 为什么binlog cache是每个线程自己维护的,而redo log buffer是全局共用? binlog不能“被打断”。一个事务的binlog必须连续写,因此要整个事务完成后,再一起写到文件。 而redo log没有这个要求,中间有生成的日志可以写到redo log buffer。redo log buffer中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘。 - 事务执行期间,还没到提交阶段,若发生crash,redo log肯定丢了,这会不会导致主备不一致呢? 不会。因为此时binlog还在binlog cache,没发给备库。crash之后,redo log和binlog都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。 - 若binlog写完盘以后发生crash,这时还没给客户端答复就重启了。等客户端再重连进来,发现事务已经提交成功了,这是不是bug? 不是。设想一下更极端场景,整个事务都提交成功,redo log commit完成了,备库也收到binlog并执行了。但主库和客户端网络断了,导致事务成功的包返回不回去,这时客户端也会收到“网络断开”的异常。这种也只能算是事务成功的,不能认为是bug。 实际上DB的crash-safe保证的是: - 如果客户端收到事务成功的消息,事务就一定持久化了 - 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了 - 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时DB只需要保证内部(数据和日志之间,主库和备库之间)一致即可。
-
一个查询语句嵌套在另一个查询语句内部的查询常用操作符:ANY(SOME), ALL, IN, EXISTS比较运算符:>, >=, <, <=, != 等创建两个表,表明分别是tb1,tb2,并向其各插入了数据。1.带ANY(SOME)关键字的子查询这里any和some是同义词,都是表示满足任一条件。例:查询tb1中的所有数据,只要大于tb2表中数据的任意值,既符合查询条件。1select num1 from tb1 where num1>ANY(select num2 from tb2);2.带ALL关键字的子查询例:查询tb1中的所有数据,返回其中比tb2表中任何值都大的数据。1select num1 from tb1 where num1>ALL(select num2 from tb2);3.带EXISTS关键字的子查询例1:查询fruits表中name字段下是否有apple,若有则查询fruits表中的记录,否则不查询。1select * from fruits where EXISTS(select name from fruits where name='apple');例2:查询tb1表中是否存在num1=13的记录,若存在,则查询fruits表中price大于6.00的记录。1select * from fruits where price>6.00 and EXISTS (select num1 from tb1 where num1=13);相反,可以使用 not exists 查询不存在的,用法相同。4.带IN关键字的子查询表示查询结果在后面子查询的范围之中例如:查询tb1表中的num1字段下的数据作为数据列,fruits表中id字段下的数据与其进行比较,返回符合条件的记录。1select * from fruits where id IN (select num1 from tb1);5.带比较运算符的子查询1select * from 表名 where 字段名 > (select ······);转载自https://www.jb51.net/article/238454.htm
-
1 为何不能直接存个NULL?NULL值列表,一行数据里可能有的字段值是NULL,比如nickname字段,允许为NULL,存储时,如果没赋值,这字段值就是NULL。假设这个字段的NULL值在磁盘存储时,就是按“NULL”字符串存储的,是不是很浪费存储空间而且还奇怪?2 到底怎么存储?不通过字符串,而是通过二进制bit位存储,一行数据里假设有多个字段的值都是NULL,那么这多个字段的NULL,就会以bit位形式存放在NULL值列表。如下表:123456CREATE TABLE customer ( name VARCHAR(10) NOT NULL, address VARCHAR(20), gender CHAR(1), job VARCHAR(30), school VARCHAR(50)) ROW_FORMAT=COMPACT;有4个变长字段,还有个定长字段,name声明NOT NULL,其他4个字段都可能NULL如下一行数据怎么存储在磁盘呢:1jack NULL m NULL xx_school有俩字段都是NULL3 一行数据的磁盘存储格式思考上面那个表里的那行案例数据,在磁盘上如何存储呢,因为他有多个变长字段,还有多个字段允许为NULL。首先我们先回顾一下,一行数据在磁盘上的存储格式应该是下面这样的:变长字段长度列表 NULL值列表 头信息 column1=value1 column2=value2 ... columnN=valueN4个变长字段,逆序先放school字段的长度,再放job、address、name几个字段的值长度?但要区分一个问题,若这变长字段值为NULL,就不用在变长字段长度列表里存放他的值长度,所以在上面那行数据,只有name和school两个变长字段有值,把他们的长度按照逆序放在变长字段长度列表即可:0x09 0x04 NULL值列表 头信息 column1=value1 column2=value2 ... columnN=valueN所有允许值为NULL的字段,不是说值就得是NULL,只要是允许为NULL的字段,每个字段都有个二进制bit位值:• bit值是1说明是NULL• bit值是0说明不是NULL比如上面4个字段都允许为NULL,每个人都会有一个bit位,这一行数据的值是1jack NULL m NULL xx_school其中2个字段是null,2个字段不是null,所以4个bit位应该是:1010但实际放在NULL值列表时,按逆序放,所以NULL值列表里放的:0101,整体这一行数据如下:0x09 0x04 0101 头信息 column1=value1 column2=value2 ... columnN=valueN实际NULL值列表存放时,一般起码8个bit位的倍数,不足8个bit位就高位补0,所以实际存放如下:0x09 0x04 00000101 头信息 column1=value1 column2=value2 ... columnN=valueN4 如何读磁盘的一行数据?磁盘数据存储格式:0x09 0x04 00000101 头信息 column1=value1 column2=value2 ... columnN=valueN先读:• 变长字段长度列表就知道有几个变长字段• NULL值列表哪些变长字段是NULL:从变长字段长度列表中解析出不为NULL的变长字段的值长度,然后也知道哪些字段是NULL。根据这些信息,就能从实际的列值存储区域里,把你每个字段的值读取出来了。• 变长字段值,就按值长度读取,若为NULL,就知道他是个NULL,没有值存储• 定长字段,按定长长度读取转载自https://www.jb51.net/article/238435.htm
-
一、适合创建索引1、字段的数值有唯一性限制根据Alibaba规范,指明在业务上具有唯一特性的字段,即使是组合字段,也必须建成唯一索引。例如,学生表中的学号时具有唯一性的字段,为该字段建立唯一性索引可以快速查询出某个学生的信息,如果使用姓名的话,可能存在同名的情况,从而降低查询速度。2、频繁作为Where查询条件的字段某个字段在Select语句的Where条件中经常被使用到,那么就需要给这个字段创建索引,尤其实在数据量大的情况下,创建普通索引就可以大幅提升查询效率。比如测试表student_info有100万数据,假设查询student_id=112322的用户信息,如果没有对student_id字段创建索引,查询结果如下:1select course_id, class_id, name, create_time,student_id from student_info where student_id = 112322;# 花费211ms为student_id创建索引后,查询结果如下:12alter table student_info add index idx_sid(student_id);select course_id, class_id, name, create_time,student_id from student_info where student_id = 112322;# 花费3ms3、经常Group by和Order by的列索引就是让数据按照某种顺序进行存储或检索,因此当使用Group by对数据进行分组查询或使用Order by对数据进行排序的时候 ,就需要对分组或排序的字段进行索引。如果待排序的列有多个,那可以在这些列上建立组合索引。比如,按照student_id对学生选秀的课程进行分组,显示不同的student_id和课程的数量,显示100条。如果不对student_id创建索引,查询结果如下:1select student_id,count(*) as num from student_info group by student_id limit 100;#花费2.466s为student_id创建索引后,查询结果如下:12alter table student_info add index idx_sid(student_id);select student_id,count(*) as num from student_info group by student_id limit 100;#花费6ms对于既有group by又有order by的查询语句,建议最好建立联合索引,并且将group by中的字段放到order by字段的前边,满足‘最左前缀匹配原则’,这样索引的利用率就会高,自然查询的效率也就会高;同时8.0之后的版本支持降序索引,如果order by之后的字段时降序的,可以考虑直接创建降序索引,也会提高查询效率。4、Update、Delete的where条件列对数据按照某个条件进行查询后再进行Update或Delete的操作,如果对Where字段创建了索引,就能答复提升效率。原因是因为需要先根据Where条件列检索出来这条记录,然后再对他进行更新或删除。如果进行更新的时候,更新的字段是非索引字段,提升效率会更明显,这是因为费索引字段更新不需要对所以进行维护。比如对student_info表中的name字段为sdfasdfas123123的数据修改student_id为110119,在没有对name字段建立索引的情况下,执行情况如下:1update student_info set student_id = 110119 where name = 'sdfasdfas123123';#花费549ms添加索引后,执行情况如下:12alter table student_info add index idx_name(name);update student_info set student_id = 110119 where name = 'sdfasdfas123123';#花费2ms5、Distinct字段需要创建索引有时候需要对某个字段进行去重,使用Distinct,那么对这个创建索引也会提升查询效率。比如查询课程表中不同student_id都有哪些,如果没有为student_id创建索引,执行情况如下:1select distinct(student_id) from student_id;#花费2ms创建索引后,执行情况如下:12alter table student_info add index idx_sid(student_id);select distinct(student_id) from student_id;#花费0.1ms6、多表Join连接操作时,创建索引注意事项首先,连接表的数据量尽量不超过3张,因为每增加一张表就相当于增加了一次嵌套的循环,数量级增长非常快,严重影响查询效率。其次,对Where条件创建索引,因为Where才是对数据条件的过滤,如果再数据量非常大的情况下,没有Where条件过滤时非常可怕的,最后,对于连接的字段创建索引,并且改字段再多张表中类型必须一致。比如,只对student_id创建索引,查询结果如下:1234select course_id, name, student_info.student_id,course_namefrom student_info join courseon student_info.course_id = course.course_idwhere name = 'aAAaAA'; #花费176ms给name字段创建索引后,查询结果如下:12345alter table student_info add index idx_name(name);select course_id, name, student_info.student_id,course_namefrom student_info join courseon student_info.course_id = course.course_idwhere name = 'aAAaAA'; #花费2ms7、使用列的类型小的创建索引这里所说的类型小值意思是该类型表示的数据范围的大小。比如在定义表结构的时候要显示的指定列的类型,以整数类型为例,有TINYINT、MEDIUMINT、INT、BIGINT等,他们占用的存储空间依次递增,能表示的数据范围也是一次递增。如果相对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引列使用较小的类型,例如能使用INT不要使用BIGINT,能使用MEDIUMINT不使用INT,原因如下:• 数据类型越小,在查询时进行的比较操作越快• 数据类型越小,索引占用的空间就越少,在一个数据页内就可以存下更多的记录,从而减少磁盘I/O带来的性能损耗,也就意味着可以存储更多的数据在数据页中,提高读写效率。上述对于主键来说很合适,因为在聚簇索引中既存储了数据,也存储了索引,可以很好的减少磁盘I/O;而对于二级索引来说,还需要一次回表操作才能查到完整的数据,也就能加了一次磁盘I/O。8、使用字符串前缀创建索引根据Alibaba开发手册,在字符串上建立索引时,必须指定索引长度,没有必要对全字段建立索引。比如有一张商品表,表中的商品描述字段较长,在描述字段上建立前缀索引如下:12create table product(id int, desc varchar(120) not null);alter table product add index(desc(12));区分度的计算可以使用count(distinct left(列名, 索引长度))/count(*)来确定。9、区分度高的列适合作为索引列的基数值得时某一列中不重复数据的个数,比如说某个列包含值2,5,3,6,2,7,2,虽然有7条记录,但该列的基数却是5,也就是说,在记录行数一定的情况下,列的基数越大,该列中的值就越分散;列的基数越小,该列中的值就越集中。这里列的基数指标非常重要,直接影响是否能有效利用索引。最好为列的基数大的列建立索引,为基数太小的列建立索引效果反而不好。可以使用公式select count(distinct col)/count(*) from table 来计算区分度,越接近1区分度越好。10、使用最频繁的列放到联合索引的左侧这条就是通常说的最左前缀匹配原则。 通俗来讲就是将Where条件后经常使用的条件字段放在索引的最左边,将使用频率相对低的放到右边。11、在多个字段都要创建索引的情况下,联合索引由于单值索引二、不适合创建索引1、在where中使用不到的字段不要设置索引通常索引的建立是有代价的,如果建立索引的字段没有出现在where条件(包括group by、order by)中,建议一开始就不要创建索引或将索引删除,因为索引的存在也会占用空间。2、数据量小的表最好不要使用索引3、有大量重复数据的列上不要建立索引在条件表达式中经常用到的不同值较多的列上建立索引,但字段中如果有大量重复数据,也不用创建索引。比如学生表中的性别字段,只有男和女两种值,因此无需建立索引。如果建立索引,不但不会提高查询效率,反而会严重降低数据更新速度。4、避免对经常更新的表创建过多的索引• 频繁更新的字段不一定要创建索引,因为更新数据的时候,索引也要跟着更新,如果索引太多,更新的时候会造成服务器压力,从而影响效率。• 避免对经常更新的表创建过多的索引,并且索引中的列尽可能少。此时虽然提高了查询速度,同时也会降低更新表的速度。5、不建议用无序的值作为索引例如身份证、UUID(在索引比较时需要转为ASCII,并且插入时可能造成页分裂)、MD5、HASH、无序长字符串等。6、删除不在使用或很少使用的索引表中的数据被大量更新或者数据的使用方式被改变后,原有的一些索引可能不会被使用到。DBA应定期找出这些索引并将之删除,从而较少无用索引对更新操作的影响。7、不要定义冗余或重复的索引例如身份证、UUID(在索引比较时需要转为ASCII,并且插入时可能造成页分裂)、MD5、HASH、无序长字符串等。8、删除不在使用或很少使用的索引表中的数据被大量更新或者数据的使用方式被改变后,原有的一些索引可能不会被使用到。DBA应定期找出这些索引并将之删除,从而较少无用索引对更新操作的影响。9、不要定义冗余或重复的索引转载自https://www.jb51.net/article/238903.htm
-
前言聚簇索引就是innodb默认创建的基于主键的索引结构,而且表里的数据就是直接放在聚簇索引里,作为叶节点的数据页:基于主键的数据搜索:从聚簇索引的根节点开始进行二分查找,一路找到对应数据页,基于页目录就直接定位到主键目标数据。若想对其它字段建立索引,甚至是基于多个字段建立联合索引,此时索引结构又是咋样?假设对其他字段建立索引,如name、age之类,都是一样原理。比如你插入数据时:• 把完整数据插入聚簇索引的叶节点的数据页,同时维护好聚簇索引• 为你其他字段建立的索引,重新再建立一颗B+树比如你基于name字段建立了一个索引,当插入数据时,就会重新搞一颗B+树,B+树的叶节点也是数据页,但该数据页里仅放主键字段和name字段:这是独立于聚簇索引之外的另一个name字段的B+索引树,其叶节点的数据页仅存放主键和name字段值。整体排序规则都跟聚簇索引按照主键的排序规则是一样,即:• 叶节点的数据页中的name值都是排序的• 下一个数据页里的name字段值都>上一个数据页里的name字段值name字段的索引B+树也会构建多层级的索引页,索引页里存放:• 下一层的页号• 最小name字段值,根据name字段值排序。所以若你根据name字段查数据,过程也一样,从name索引树的根节点开始,一层一层往下找,一直找到叶节点的数据页,定位到name字段值对应的主键值。然后针对1select * from t where name='xx'这种语句,先根据name值在name索引树里找,找到叶节点,也仅能找到对应主键值,而找不到这行数据的所有字段。所以还需回表:还需根据主键值,再到聚簇索引里从根节点开始,找到叶节点的数据页,定位到主键值对应的完整数据行,此时才能把select *要的全部字段值都取出。联合索引比如name+age,运行流程同理,建立一个独立的B+树,叶节点的数据页存放id+name+age后,默认按name排序,name一样就按age排,不同数据页之间的name+age值的排序也如此。然后这个name+age的联合索引的B+树的索引页存放:• 下一层节点的页号• 最小的name+age的值所以当你根据name+age搜索时,就会走name+age联合索引树,搜索到主键,再根据主键到聚簇索引里去搜索。总结以上就是InnoDB索引的实现原理,就是建立B+树,层层二分查找。不同的索引就是建立了不同B+树,然后增删改时:在数据页里更新数据维护你所有的索引转载自https://www.jb51.net/article/238738.htm
-
1.查询select * from 表名;示例: select * from student where id=1 -- 查询id=1学生的所有信息2.添加insert into 表名 values(添加的数据,对应表中的列);insert into 表名(列1,列2,列3...) values(列1对应的值,列1对应的值,列1对应的值...)3.更新update 表名 set 更新的数据 where 条件;示例: update clazz set teacher_name = '王鹏飞' where id = 2 --将所有同学的年龄+1 update student set age = age+1 4.删除delete from 表名 where 条件; 删除表中的内容truncate table 表名 删除表5.函数 1). sum函数:用于计算数值型的列的总和,值为Null的将被忽略 语法:select sum(列名) from 表名 [where 条件表达式] 示例: select sum(score) as Java成绩总和 from stu_marks where subject='Java' --查询java成绩的总和 2). avg:用于计算数值型的列的总和,值为Null的将被忽略 select sum(列名) from 表名 [where 条件表达式] 示例: select avg(score) as Java平均成绩 from stu_marks where subject='Java' --查询java成绩的平均值 3). max:用于计算数值型的列里面最大的值,值为Null的将被忽略 select sum(列名) from 表名 [where 条件表达式] 示例: select max(score) as Java最高成绩 from stu_marks where subject='Java' --查询java的最高成绩 4). min:用于计算数值型的列里面最大的值,值为Null的将被忽略 select min(列名) from 表名 [where 条件表达式] 示例: select min(score) as Java最低成绩 from stu_marks where subject='Java' --查询java的最低成绩 5). count:用于统计查询的列一共有几行数据 select min(列名) from 表名 [where 条件表达式] 示例: select count(*) as Java考试学生数 from stu_marks where subject='Java' --查询参加java考试的学生数 6). 查询结果分组,将指定列的值相同的行进行分组:group by 列名,指定的列必须出现在查询列或者聚合函数中 示例: select subject as 科目,avg(score) as 人数 from stu_marks group by subject --分别统计每个科目的平均分 6. 给表添加约束(简便方法是在创建表的时候就写上约束) 添加主键约束:alter table 表名 add constraint PK_列名 primary key(列名) 添加唯一约束:alter table 表名 add constraint UQ_列名 unique(列名) 添加外键约束:alter table 表名 add constraint FK_列名 foreign key(列名) references 外部表(外部表对应的列) 添加检查约束:alter table 表名 add constraint CK_列名 check(检查条件) 添加默认值约束: alter table 表名 add constraint DF_列名 default(默认值)7. where筛选条件 判断值相等: 列名=值 ,比如:where age=2 判断值不相等: 列名<>值,比如: where age<>2 判断值在某个区间内: between x and y ,比如判断年龄在18到50岁: where age between 18 and 50 判断值在某个已知值的范围内: in ,比如判断id在1,3,5内 : where id in(1,3,5) 判断值是空值:where teacher_name is null --查询教师名字是空的那些数据 判断同时满足多个条件:where id in (1,3,5) AND sex = '女' --判断性别为女,同时id在1,3,5范围内的数据 判断满足多个条件其中任意一个:where id in (1,3,5) AND sex = '女' -- 判断性别为女,或者id在1,3,5范围内的数据 8. 模糊匹配:like 或 not like %: 匹配0个或任意个字符,比如:'李%',匹配'李'开头的字符串 _: 匹配任意一个字符,比如:'李_',匹配'李'开头的字符串,并且后面只跟一个字符 []: 匹配方括号里面其中任意一个字符,比如:'李[四五六]',匹配李四或者李五或者李六 [^]:匹配任意一个字符,除了方括号里的字符:'李[^四五六]',匹配李八或者李九,但是不匹配李四或者李五或者李六 9. 查询结果排序: order by 列名 升序或者降序
-
一、一条查询SQL是如何执行的?1.客服端/服务器通信协议2.查询缓存,若缓存中有数据直接返回,若没有执行第三步3.解析器进行语法解析,解析树预处理给到新解析树,4.查询优化器5执行计划,查询计划引擎6.返回结果,并缓存结果。API接口查询到存储引擎1.通信协议 首先,MySQL 必须要运行一个服务,监听默认的3306端口MYSQL支持TCP/IP 协议,编程语言的连接模块都是用 TCP 协议连接到 MySQL 服务器的。MYSQL还支持Unix Socket。比如我们在 Linux 服务器,不用通过网络协议,也可以连接到 MySQL 的服务器。2.通信方式分为三种A(发送器)--->B(接收器) 单工,数据单向传输A(发送器,接收器)<--->B(发送器,接收器) 半双工,数据双向传输,但是不能同时传输A(发送器,接收器)<===>B(发送器,接收器) 双工,数据双向传输,同时传输MySQL 使用半双工的通信方式。 半双工意味着要么是客户端向服务端发送数据,要么是服务端向客户端发送数据,这两个动作不能 同时发生。 所以客户端发送 SQL 语句给服务端的时候,(在一次连接里面)数据是不能分成小块发送的,不管 你的 SQL 语句有多大,都是一次性发送。 如果发送给服务器的数据包过大,我们必须要调整 MySQL 服务器配置 max_allowed_packet 参数的值 (默认是 4M)另一方面,对于服务端来说,也是一次性发送所有的数据,不能因为你已经取到了想要的数据就中 断操作。 所以,我们一定要在程序里面避免不带 limit 的这种操作。连接方式8小时不活动的连接,MySQL 服务器会断开。MySQL 默认的最大连接数是 151 个(5.7 版本)Sleep 线程正在等待客户端,以向它发送一个新语句 Query 线程正在执行查询或往客户端发送数据 Locked 该查询被其它查询锁定 Copying to tmp table 临时结果集合大于 tmp_table_size。线程把临时表从存储器内部格式改变为磁 盘模式,以节约存储器 Sending data 线程正在为 SELECT 语句处理行,同时正在向客户端发送数据 Sorting for group 线程正在进行分类,以满足 GROUP BY 要求 Sorting for order 线程正在进行分类,以满足 ORDER BY 要求 查询缓存MySQL 内部自带了一个缓存模块。默认是关闭的。主要是因为 MySQL 自带的缓存的应用场景有 限,第一个是它要求 SQL 语句必须一模一样。第二个是表里面任何一条数据发生变化的时候,这张表所 有缓存都会失效。语法解析和预处理(Parser & Preprocessor) 假如随便执行一个字符串 fkdljasklf ,服务器报了一个 1064 的错: [Err] 1064 - You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'fkdljasklf' at line 1 服务器是怎么知道我输入的内容是错误的? 或者,当我输入了一个语法完全正确的 SQL,但是表名不存在,它是怎么发现的? 这个就是 MySQL 的 Parser 解析器和 Preprocessor 预处理模块。 这一步主要做的事情是对 SQL 语句进行词法和语法分析和语义的解析词法解析 词法分析就是把一个完整的 SQL 语句打碎成一个个的单词。 比如一个简单的 SQL 语句: select name from user where id = 1; 它会打碎成 8 个符号,记录每个符号是什么类型,从哪里开始到哪里结束。语法解析 第二步就是语法分析,语法分析会对 SQL 做一些语法检查,比如单引号有没有闭合,然后根据 MySQL 定义的语法规则,根据 SQL 语句生成一个数据结构。这个数据结构我们把它叫做解析树预处理器(Preprocessor) 如果表名错误,会在预处理器处理时报错。 它会检查生成的解析树,解决解析器无法解析的语义。比如,它会检查表和列名是否存在,检查名 字和别名,保证没有歧义。 4、查询优化(Query Optimizer)与查询执行计划 什么优化器? 问题:一条 SQL 语句是不是只有一种执行方式?或者说数据库最终执行的 SQL 是不是就是我们发 送的 SQL? 这个答案是否定的。一条 SQL 语句是可以有很多种执行方式的。但是如果有这么多种执行方式,这 些执行方式怎么得到的?最终选择哪一种去执行?根据什么判断标准去选择? 这个就是 MySQL 的查询优化器的模块(Optimizer)。 查询优化器的目的就是根据解析树生成不同的执行计划,然后选择一种最优的执行计划,MySQL 里 面使用的是基于开销(cost)的优化器,那种执行计划开销最小,就用哪种。 使用如下命令查看查询的开销: show status like 'Last_query_cost'SHOW VARIABLES LIKE 'optimizer_trace'; set optimizer_trace="enabled=on"; select t.tcid from teacher t,teacher_contact tc where t.tcid = tc.tcid; select * from information_schema.optimizer_trace\G
-
GaussDB(DWS)项目实践--迁移系列文章汇总,请大家阅读鉴赏,欢迎在评论区交流探讨~序号主题分类标题链接1迁移系列DWS自定义函数列表1https://bbs.huaweicloud.com/forum/thread-175508-1-1.html2迁移系列数据库迁移时数据无法访问问题处理https://bbs.huaweicloud.com/forum/thread-175020-1-1.html3迁移系列【TD自定义函数】specialdata(inputdata varchar,inputtype varchar)https://bbs.huaweicloud.com/forum/thread-174385-1-1.html4迁移系列【TD自定义函数】month1_betweenhttps://bbs.huaweicloud.com/forum/thread-174381-1-1.html5迁移系列【TD自定义函数】month_between1(p1,p2)https://bbs.huaweicloud.com/forum/thread-174380-1-1.html6迁移系列【TD自定义函数】month_between(p1,p2)https://bbs.huaweicloud.com/forum/thread-174376-1-1.html7迁移系列【TD自定义函数】min_col(p1,p2)https://bbs.huaweicloud.com/forum/thread-174369-1-1.html8迁移系列【TD自定义函数】max_col(p1,p2)https://bbs.huaweicloud.com/forum/thread-174362-1-1.html9迁移系列【TD自定义函数】edit_distance_similarity(p1,p2)https://bbs.huaweicloud.com/forum/thread-174359-1-1.html10迁移系列【TD自定义函数】DPT_MOB12(p1,p2)https://bbs.huaweicloud.com/forum/thread-174349-1-1.html11迁移系列【TD自定义函数】ADD_MONTHS3https://bbs.huaweicloud.com/forum/thread-174347-1-1.html12迁移系列【TD自定义函数】ADD_MONTHS2https://bbs.huaweicloud.com/forum/thread-174324-1-1.html13迁移系列【TD自定义函数】td_length(text)https://bbs.huaweicloud.com/forum/thread-174009-1-1.html14迁移系列【TD自定义函数】lpadhttps://bbs.huaweicloud.com/forum/thread-174007-1-1.html15迁移系列【TD自定义函数】td_lefthttps://bbs.huaweicloud.com/forum/thread-174003-1-1.html16迁移系列【TD自定义函数】td_instrhttps://bbs.huaweicloud.com/forum/thread-174000-1-1.html17迁移系列【TD自定义函数】td_day_of_year(timestamp)https://bbs.huaweicloud.com/forum/thread-173997-1-1.html18迁移系列【TD自定义函数】td_day_of_year(date)https://bbs.huaweicloud.com/forum/thread-173996-1-1.html19迁移系列【TD自定义函数】td_day_of_week(timestamp)https://bbs.huaweicloud.com/forum/thread-173994-1-1.html20迁移系列【TD自定义函数】td_day_of_week(date)https://bbs.huaweicloud.com/forum/thread-173993-1-1.html21迁移系列【TD自定义函数】td_day_of_month(timestamp)https://bbs.huaweicloud.com/forum/thread-173989-1-1.html22迁移系列【TD自定义函数】td_day_of_month(date)https://bbs.huaweicloud.com/forum/thread-173987-1-1.html23迁移系列【TD自定义函数】td_day_of_calendarhttps://bbs.huaweicloud.com/forum/thread-173986-1-1.html24迁移系列【TD自定义函数】td_character_lengthhttps://bbs.huaweicloud.com/forum/thread-173984-1-1.html25迁移系列【TD自定义函数】td_characterhttps://bbs.huaweicloud.com/forum/thread-173983-1-1.html26迁移系列【TD自定义函数】td_substr(text,integer,integer)https://bbs.huaweicloud.com/forum/thread-173959-1-1.html27迁移系列【TD自定义函数】td_substrhttps://bbs.huaweicloud.com/forum/thread-173954-1-1.html28迁移系列【TD自定义函数】td_righthttps://bbs.huaweicloud.com/forum/thread-173946-1-1.html29迁移系列【TD自定义函数】td_regexp_replace(text,text,text,integer,integer,text)https://bbs.huaweicloud.com/forum/thread-173945-1-1.html30迁移系列【TD自定义函数】td_regexp_replacehttps://bbs.huaweicloud.com/forum/thread-173944-1-1.html31迁移系列【TD自定义函数】td_quarter_of_yearhttps://bbs.huaweicloud.com/forum/thread-173943-1-1.html32迁移系列【TD自定义函数】td_month_end(timestamp)https://bbs.huaweicloud.com/forum/thread-173942-1-1.html33迁移系列【TD自定义函数】td_month_end(date)https://bbs.huaweicloud.com/forum/thread-173941-1-1.html34迁移系列【TD自定义函数】td_month_begin(timestamp)https://bbs.huaweicloud.com/forum/thread-173939-1-1.html35迁移系列【TD自定义函数】td_month_beginhttps://bbs.huaweicloud.com/forum/thread-173937-1-1.html36迁移系列【TD自定义函数】months_between_double(date,date)https://bbs.huaweicloud.com/forum/thread-173936-1-1.html37迁移系列【TD自定义函数】months_between(date,date)https://bbs.huaweicloud.com/forum/thread-173935-1-1.html38迁移系列【TD自定义函数】monthnumber_of_year(date)https://bbs.huaweicloud.com/forum/thread-173927-1-1.html39迁移系列【TD自定义函数】daynumber_of_year(date)https://bbs.huaweicloud.com/forum/thread-173911-1-1.html40迁移系列【TD自定义函数】daynumber_of_week(date)https://bbs.huaweicloud.com/forum/thread-173909-1-1.html41迁移系列【TD自定义函数】td_char_lengthhttps://bbs.huaweicloud.com/forum/thread-173904-1-1.html42迁移系列【TD自定义函数】td_charhttps://bbs.huaweicloud.com/forum/thread-173902-1-1.html43迁移系列【TD自定义函数】daynumber_of_month(date)https://bbs.huaweicloud.com/forum/thread-173900-1-1.html44迁移系列【TD自定义函数】strtokhttps://bbs.huaweicloud.com/forum/thread-173899-1-1.html45迁移系列【TD自定义函数】daynumber_of_calendar(date)https://bbs.huaweicloud.com/forum/thread-173897-1-1.html46迁移系列【TD自定义函数】round2https://bbs.huaweicloud.com/forum/thread-173896-1-1.html47迁移系列【TD自定义函数】regexp_substr(text,text,text)https://bbs.huaweicloud.com/forum/thread-173895-1-1.html48迁移系列【TD自定义函数】regexp_substrhttps://bbs.huaweicloud.com/forum/thread-173894-1-1.html49迁移系列【TD自定义函数】chars(text)https://bbs.huaweicloud.com/forum/thread-173892-1-1.html50迁移系列【TD自定义函数】zeroifnullhttps://bbs.huaweicloud.com/forum/thread-173891-1-1.html51迁移系列【TD自定义函数】regexp_substrhttps://bbs.huaweicloud.com/forum/thread-173890-1-1.html52迁移系列【TD自定义函数】regexp_similarhttps://bbs.huaweicloud.com/forum/thread-173888-1-1.html53迁移系列【TD自定义函数】yearnumber_of_yearhttps://bbs.huaweicloud.com/forum/thread-173887-1-1.html54迁移系列【TD自定义函数】char2hexint(text)https://bbs.huaweicloud.com/forum/thread-173886-1-1.html55迁移系列【TD自定义函数】regexp_instrhttps://bbs.huaweicloud.com/forum/thread-173885-1-1.html56迁移系列【TD自定义函数】regexp_instrhttps://bbs.huaweicloud.com/forum/thread-173884-1-1.html57迁移系列【TD自定义函数】weeknumber_of_yearhttps://bbs.huaweicloud.com/forum/thread-173883-1-1.html58迁移系列【TD自定义函数】substring_index(str,delim,count)https://bbs.huaweicloud.com/forum/thread-173882-1-1.html59迁移系列【TD自定义函数】quarternumber_of_yearhttps://bbs.huaweicloud.com/forum/thread-173879-1-1.html60迁移系列【TD自定义函数】weeknumber_of_calendarhttps://bbs.huaweicloud.com/forum/thread-173877-1-1.html61迁移系列【TD自定义函数】oadd_monthshttps://bbs.huaweicloud.com/forum/thread-173876-1-1.html62迁移系列【TD自定义函数】trunchttps://bbs.huaweicloud.com/forum/thread-173875-1-1.html63迁移系列【TD自定义函数】numtoymintervalhttps://bbs.huaweicloud.com/forum/thread-173872-1-1.html64迁移系列【TD自定义函数】ADD_MONTHS2https://bbs.huaweicloud.com/forum/thread-173862-1-1.html65迁移系列GaussDB(DWS)实践系列-MySQL->GaussDB(DWS)常用语法对照表https://bbs.huaweicloud.com/forum/thread-146647-1-1.html66迁移系列数据校验https://bbs.huaweicloud.com/forum/thread-141185-1-1.html67迁移系列【MySQL语法迁移】str_to_date()迁移https://bbs.huaweicloud.com/forum/thread-136679-1-1.html68迁移系列【MySQL语法迁移】date_format()语法迁移https://bbs.huaweicloud.com/forum/thread-136677-1-1.html69迁移系列【MySQL语法迁移】分页查询https://bbs.huaweicloud.com/forum/thread-136674-1-1.html70迁移系列【MySQL语法迁移】double类型迁移https://bbs.huaweicloud.com/forum/thread-136673-1-1.html71迁移系列【MySQL语法迁移】AUTO_INCREMENT自增列迁移https://bbs.huaweicloud.com/forum/thread-136672-1-1.html72迁移系列【MySQL语法迁移】Datetime类型https://bbs.huaweicloud.com/forum/thread-136671-1-1.html73迁移系列【MySQL语法迁移】表注释迁移https://bbs.huaweicloud.com/forum/thread-136669-1-1.html74迁移系列【MySQL语法迁移】列注释迁移https://bbs.huaweicloud.com/forum/thread-136668-1-1.html75迁移系列RDS-DRS-DWS数据复制链路优化方案比对https://bbs.huaweicloud.com/forum/thread-134266-1-1.html76迁移系列oracle列转行unpivot函数语法转换https://bbs.huaweicloud.com/forum/thread-133591-1-1.html77迁移系列【Oracle语法迁移】存储过程中table%Rowtype语法改写https://bbs.huaweicloud.com/forum/thread-132423-1-1.html78迁移系列【Oracle语法迁移】pivot语法https://bbs.huaweicloud.com/forum/thread-123046-1-1.html79迁移系列【Oracle语法迁移】Max/Min...keep语法https://bbs.huaweicloud.com/forum/thread-123044-1-1.html80迁移系列 Oracle 分析函数 keep改造方案https://bbs.huaweicloud.com/forum/thread-122150-1-1.html81迁移系列Gauss 子查询性能调优https://bbs.huaweicloud.com/forum/thread-122147-1-1.html82迁移系列 Oralce in相关子查询中包含distinct Gauss适配改造https://bbs.huaweicloud.com/forum/thread-122146-1-1.html83迁移系列 Oralce listagg/stragg/WM_CONCAT 迁移改造方案https://bbs.huaweicloud.com/forum/thread-122145-1-1.html84迁移系列oracle 递归改造案例https://bbs.huaweicloud.com/forum/thread-122143-1-1.html85迁移系列GaussDB 中实现current_user下推的方法https://bbs.huaweicloud.com/forum/thread-122131-1-1.html86迁移系列Oracle package 在gauss中的替换方法https://bbs.huaweicloud.com/forum/thread-122103-1-1.html87迁移系列gauss与oracle存储过程差异和改写方法https://bbs.huaweicloud.com/forum/thread-122102-1-1.html88迁移系列GaussDB和Oracle DDL的差异和替换示例https://bbs.huaweicloud.com/forum/thread-122101-1-1.html89迁移系列Gauss 和 Oracle DML的差异和规避方案https://bbs.huaweicloud.com/forum/thread-122100-1-1.html90迁移系列Gauss DB(DWS)迁移系列-Oracle 函数转换差异(四)https://bbs.huaweicloud.com/forum/thread-122099-1-1.html91迁移系列Gauss DB(DWS)迁移系列-Oracle 函数转换差异(二)https://bbs.huaweicloud.com/forum/thread-122098-1-1.html92迁移系列Gauss DB(DWS)迁移系列-Oracle 函数转换差异(一)https://bbs.huaweicloud.com/forum/thread-122097-1-1.html93迁移系列Gauss DB(DWS)迁移系列-Oracle 数据类型差异https://bbs.huaweicloud.com/forum/thread-122096-1-1.html94迁移系列[项目实践-迁移系列] Gauss DB(DWS)迁移系列-Oracle迁移分析函数https://bbs.huaweicloud.com/forum/thread-122095-1-1.html95迁移系列Oracle + APEX 迁移至 DWS + 帆软https://bbs.huaweicloud.com/forum/thread-120739-1-1.html96迁移系列使用Python连接GaussDB(DWS)https://bbs.huaweicloud.com/forum/thread-119609-1-1.html97迁移系列DWS perl通过ODBC连接GaussDB(DWS)https://bbs.huaweicloud.com/forum/thread-119608-1-1.html98迁移系列Gauss DB(DWS) DWS与OBS数据互导https://bbs.huaweicloud.com/forum/thread-119433-1-1.html99迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-分区定义设置Partition by https://bbs.huaweicloud.com/forum/thread-117993-1-1.html100迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-字段字符集设置Character set Latinhttps://bbs.huaweicloud.com/forum/thread-117990-1-1.html101迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-字段多值压缩属性compresshttps://bbs.huaweicloud.com/forum/thread-117989-1-1.html102迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-字段数据格式化显式属性formathttps://bbs.huaweicloud.com/forum/thread-117988-1-1.html103迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-视图迁移差异https://bbs.huaweicloud.com/forum/thread-117985-1-1.html104迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-大小写敏感CASESPECIFIC/NOT CASESPECIFIChttps://bbs.huaweicloud.com/forum/thread-117982-1-1.html105迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-行列存存储格式https://bbs.huaweicloud.com/forum/thread-117981-1-1.html106迁移系列TD常用字典视图在GaussDB(DWS)上实现https://bbs.huaweicloud.com/forum/thread-117977-1-1.html107迁移系列ORACLE 触发器在GaussDB(DWS)上的实现https://bbs.huaweicloud.com/forum/thread-117970-1-1.html108迁移系列oracle CONNECT_BY_ROOT,CONNECT_BY_ISLEAF等在GaussDB(DWS)实现https://bbs.huaweicloud.com/forum/thread-117964-1-1.html109迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-是否允许重复记录Set/MultiSethttps://bbs.huaweicloud.com/forum/thread-117955-1-1.html110迁移系列Gauss DB(DWS)迁移系列-TD迁移DDL差异-可变临时表Volatilehttps://bbs.huaweicloud.com/forum/thread-117952-1-1.html111迁移系列Gauss DB(DWS)迁移系列-TD的日期类型和数值型互转操作差异https://bbs.huaweicloud.com/forum/thread-117946-1-1.html112迁移系列【Teradata语法迁移】VOLATILE表语法迁移改写https://bbs.huaweicloud.com/forum/thread-117690-1-1.html113迁移系列【Teradata语法迁移】Teradata set语法迁移案例https://bbs.huaweicloud.com/forum/thread-117686-1-1.html114迁移系列【Oracle语法迁移】Rownum语法迁移案例https://bbs.huaweicloud.com/forum/thread-114322-1-1.html115迁移系列【Oracle语法迁移】connect_by_root语法迁移案例https://bbs.huaweicloud.com/forum/thread-114317-1-1.html116迁移系列【Oracle语法迁移】order siblings by语法迁移案例https://bbs.huaweicloud.com/forum/thread-114312-1-1.html117迁移系列【Oracle语法迁移】sys_connect_by_path语法迁移案例https://bbs.huaweicloud.com/forum/thread-114307-1-1.html118迁移系列【Oracle语法迁移】单表递归语法迁移案例https://bbs.huaweicloud.com/forum/thread-114304-1-1.html119迁移系列GaussBD A 8.0.0.3 从Oracle往GaussDB数据库迁移数据https://bbs.huaweicloud.com/forum/thread-114280-1-1.html120迁移系列Gauss DB(DWS)迁移系列-TD迁移函数之next函数https://bbs.huaweicloud.com/forum/thread-114003-1-1.html121迁移系列GaussDB(DWS) encode函数base64转码后存在换行https://bbs.huaweicloud.com/forum/thread-113489-1-1.html122迁移系列Gauss DB(DWS)迁移系列-Teradata迁移PIVOT/UNPIVOT 语法转换对照https://bbs.huaweicloud.com/forum/thread-113049-1-1.html123迁移系列ADB for mysql 【INSERT [IGNORE] INTO table_name】迁移 DWS 改写方法https://bbs.huaweicloud.com/forum/thread-111391-1-1.html124迁移系列数据仓库,数据脱敏、and,or,in处置方案https://bbs.huaweicloud.com/forum/thread-99866-1-1.html125迁移系列ADB for mysql 【INSERT [IGNORE] INTO table_name】迁移 DWS 改写方法https://bbs.huaweicloud.com/forum/thread-93001-1-1.html126迁移系列GDS实践指南https://bbs.huaweicloud.com/forum/thread-90062-1-1.html127迁移系列查询GDS外表时报错:connect failedhttps://bbs.huaweicloud.com/forum/thread-85894-1-1.html128迁移系列GaussDB A使用本地模式外表导出和导入数据https://bbs.huaweicloud.com/forum/thread-84489-1-1.html129迁移系列Roach基于NAS的备份https://bbs.huaweicloud.com/forum/thread-81751-1-1.html130迁移系列Oracle数据通过sqluldr导出然后通过gds导入到GaussDB实例https://bbs.huaweicloud.com/forum/thread-71300-1-1.html131迁移系列ORACLE使用GDS导入因数据中含有单字节,使部分行导入失败https://bbs.huaweicloud.com/forum/thread-71109-1-1.html132迁移系列GDS外表导入导出的那些事。https://bbs.huaweicloud.com/forum/thread-70824-1-1.html133迁移系列gds导入导出text格式https://bbs.huaweicloud.com/forum/thread-70756-1-1.html134迁移系列自己编写的数据一致性校验函数https://bbs.huaweicloud.com/forum/thread-59893-1-1.html
-
>摘要:Kubernetes 很多看起来比较“繁琐”的设计的主要目的,都是希望为开发者提供更多的“可扩展性”,给使用者带来更多的“稳定性”和“安全感”。本文分享自华为云社区《[如何在 Kubernetes 集群中搭建一个复杂的 MySQL 数据库?](https://bbs.huaweicloud.com/blogs/302931?utm_source=zhihu&utm_medium=bbs-ex&utm_campaign=other&utm_content=content)》,作者:zuozewei 。 # 前言 实际生产环境中,为了稳定和高可用,运维团队一般不会把 MySQL 数据库部署在 Kubernetes 集群中,一般是用云厂商的数据库或者自己在高性能机器(如裸金属服务器)上搭建。 但是,对于测试开发环境,我们完全可以把 MySQL 部署到各自的 Kubernetes 集群中,非常有助于提升运维效率,而且还有助于Kubernetes 使用的经验积累。 # 简易部署 如下所示,我们仅需设置 root 用户密码(环境变量 MYSQL_ROOT_PASSWORD), 便可轻松的使用 MySQL 官方镜像构建一个 MySQL 数据库。 apiVersion: extensions/v1beta1 kind: Deployment metadata: labels: app: mysql-min name: mysql-min spec: replicas: 1 selector: matchLabels: app: mysql-min template: metadata: labels: app: mysql-min spec: containers: - image: centos/mysql-57-centos7:latest name: mysql-min imagePullPolicy: IfNotPresent env: - name: MYSQL_ROOT_PASSWORD value: admin@123 创建一 Service 以便集群内外均可访问数据库,其中集群外需通过 nodePort 设置的 30336 端口访问。 apiVersion: v1 kind: Service metadata: labels: app: mysql-min release: mysql-min name: mysql-min namespace: default spec: ports: - name: mysql port: 3306 protocol: TCP nodePort: 30336 targetPort: mysql selector: app: mysql-min #目前sessionAffinity可以提供"None""ClientIP"两种设定: #None: 以round robin的方式轮询下面的Pods。 #ClientIP: 以client ip的方式固定request到同一台机器。 sessionAffinity: None type: NodePort #status: # loadBalancer: {} 接着,访问数据库并验证其运行正常: # kubectl get pod # 当前Pod名称 NAME READY STATUS RESTARTS AGE mysql-min-5b5668c448-t44ml 1/1 Running 0 3h # 通过本机访问 # kubectl exec -it mysql-min-5b5668c448-t44ml -- mysql -uroot -padmin@123 mysql> select 1; +---+ | 1 | +---+ | 1 | +---+ # 集群内部通过mysql service访问: # kubectl exec -it mysql-min-5b5668c448-t44ml -- mysql -uroot -padmin@123 -hmysql mysql> select now(); +---------------------+ | now() | +---------------------+ | 2021-03-13 07:19:14 | +---------------------+ # 集群外部,可通过任何一个 K8S 节点访问数据库: # mysql -uroot -padmin@123 -hworker-1 -P30336 mysql> show databases; +--------------------+ | Database | +--------------------+ | information_schema | | mysql | | performance_schema | | sys | +--------------------+ # 扩展部署 ## 持久化存储 若要确保 MySQL 重启后数据仍然存在,我们需为其配置可持久化存储,我这里的实验环境使用的是 Local Persistent Volume,也就是说,我希望 Kubernetes 能够直接使用宿主机上的本地磁盘目录,而不依赖于远程存储服务,来提供“持久化”的容器 Volume。这样做的好处很明显,由于这个 Volume 直接使用的是本地磁盘,尤其是 SSD 盘,它的读写性能相比于大多数远程存储来说,要好得多。这个需求对本地物理服务器部署的私有 Kubernetes 集群来说,非常常见。 值得指出的是其次,相比于正常的 PV,一旦这些节点宕机且不能恢复时,本地存储 Volume 的数据就可能丢失。这就要求使用 其的应用必须具备数据备份和恢复的能力,允许你把这些数据定时备份在其他位置。 不难想象, Local Persistent Volume 的设计,主要面临两个难点。 **第一个难点在于:如何把本地磁盘抽象成 PV。** 可能你会说,Local Persistent Volume 不就等同于 hostPath 加 NodeAffinity 吗? 比如,一个 Pod 可以声明使用类型为 Local 的 PV,而这个 PV 其实就是一个 hostPath 类型的 Volume。如果这个 hostPath 对应的目录,已经在节点 A 上被事先创建好了。那么,我只需要再给这个 Pod 加上一个 nodeAffinity=nodeA,不就可以使用这个 Volume 了吗? 事实上,你绝不应该把一个宿主机上的目录当作 PV 使用。这是因为,这种本地目录的存储行为完全不可控,它所在的磁盘随时都可能被应用写满,甚至造成整个宿主机宕机。而且,不同的本地目录之间也缺乏哪怕最基础的 I/O 隔离机制。 所以,一个 本地存储 Volume 对应的存储介质,一定是一块额外挂载在宿主机的磁盘或者块设备(“额外”的意思是,它不应该是宿主机根目录所使用的主硬盘)。这个原则,我们可以称为“**一个 PV 一块盘**”。 **第二个难点在于:调度器如何保证 Pod 始终能被正确地调度到它所请求的本地 Volume 所在的节点上呢?** 造成这个问题的原因在于,对于常规的 PV 来说,Kubernetes 都是先调度 Pod 到某个节点上,然后,再通过“两阶段处理”来“持久化”这台机器上的 Volume 目录,进而完成 Volume 目录与容器的绑定挂载。 可是,对于 Local PV 来说,节点上可供使用的磁盘(或者块设备),必须是运维人员提前准备好的。它们在不同节点上的挂载情况可以完全不同,甚至有的节点可以没这种磁盘。 所以,这时候,调度器就必须能够知道所有节点与 Local Persistent Volume 对应的磁盘的关联关系,然后根据这个信息来调度 Pod。 这个原则,我们可以称为“在调度的时候考虑 Volume 分布”。在 Kubernetes 的调度器里,有一个叫作 VolumeBindingChecker 的过滤条件专门负责这个事情。在 Kubernetes v1.11 中,这个过滤条件已经默认开启了。 基于上述讲述,在开始使用 Local Persistent Volume 之前,你首先需要在集群里配置好磁盘或者块设备。在公有云上,这个操作等同于给虚拟机额外挂载一个磁盘,比如 GCE 的 Local SSD 类型的磁盘就是一个典型例子。 而在我们部署的私有环境中,你有两种办法来完成这个步骤。 - 第一种,当然就是给你的宿主机挂载并格式化一个可用的本地磁盘,这也是最常规的操作; - 第二种,对于实验环境,你其实可以在宿主机上挂载几个 RAM Disk(内存盘)来模拟本地磁盘。 接下来,我会使用第二种方法,在我们之前部署的 Kubernetes 集群上进行实践。首先,在名叫 node-1 的宿主机上创建一个挂载点,比如 /mnt/disks;然后,用几个 RAM Disk 来模拟本地磁盘,如下所示: # 在node-1上执行 $ mkdir /mnt/disks $ for vol in vol1 vol2 vol3; do mkdir /mnt/disks/$vol mount -t tmpfs $vol /mnt/disks/$vol done 需要注意的是,如果你希望其他节点也能支持 Local Persistent Volume 的话,那就需要为它们也执行上述操作,并且确保这些磁盘的名字(vol1、vol2 等)都不重复。接下来,我们就可以为这些本地磁盘定义对应的 PV 了,如下所示: apiVersion: v1 kind: PersistentVolume metadata: name: mysql-min-pv-local namespace: default spec: capacity: storage: 5Gi volumeMode: Filesystem accessModes: - ReadWriteOnce storageClassName: "mysql-min-storageclass-local" persistentVolumeReclaimPolicy: Retain #表示使用本地存储 local: path: /mnt/disks/vol1 #使用local pv时必须定义nodeAffinity,Kubernetes Scheduler需要使用PV的nodeAffinity描述信息来保证Pod能够调度到有对应local volume的Node上。 #创建local PV之前,你需要先保证有对应的storageClass已经创建。 nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: # pod 需要分不到的主机名,这台主机上开启了 local-pv 资源。 - node-1 可以看到,这个 PV 的定义里:local 字段,指定了它是一个 Local Persistent Volume;而 path 字段,指定的正是这个 PV 对应的本地磁盘的路径,即:/mnt/disks/vol1。 当然了,这也就意味着如果 Pod 要想使用这个 PV,那它就必须运行在 node-1 上。所以,在这个 PV 的定义里,需要有一个 nodeAffinity 字段指定 node-1 这个节点的名字。这样,调度器在调度 Pod 的时候,就能够知道一个 PV 与节点的对应关系,从而做出正确的选择。这正是 Kubernetes 实现“在调度的时候就考虑 Volume 分布”的主要方法。 接下来要创建一个 StorageClass 来描述这个 PV,如下所示: kind: StorageClass apiVersion: storage.k8s.io/v1 metadata: name: mysql-min-storageclass-local #指定存储类的供应者,比如aws, nfs等,具体取值参考官方说明。 #存储类有一个供应者的参数域,此参数域决定PV使用什么存储卷插件。参数必需进行设置 #由于demo中使用的是本地存储,所以这里写kubernetes.io/no-provisioner. provisioner: kubernetes.io/no-provisioner #volumeBindingMode 参数将延迟PVC绑定,直到 pod 被调度。 volumeBindingMode: WaitForFirstConsumer 这个 StorageClass 的名字,叫作 local-storage。需要注意的是,在它的 provisioner 字段,我们指定的是 no-provisioner。这是因为 Local Persistent Volume 目前尚不支持 Dynamic Provisioning,所以它没办法在用户创建 PVC 的时候,就自动创建出对应的 PV。也就是说,我们前面创建 PV 的操作,是不可以省略的。 与此同时,这个 StorageClass 还定义了一个 volumeBindingMode=WaitForFirstConsumer 的属性。它是 Local Persistent Volume 里一个非常重要的特性,即:延迟绑定。 >通过这个延迟绑定机制,原本实时发生的 PVC 和 PV 的绑定过程,就被延迟到了 Pod 第一次调度的时候在调度器中进行,从而保证了这个绑定结果不会影响 Pod 的正常调度。 接下来,我们只需要定义一个非常普通的 PVC,就可以让 Pod 使用到上面定义好的 Local Persistent Volume 了,如下所示: apiVersion: v1 items: - apiVersion: v1 kind: PersistentVolumeClaim metadata: #当启用PVC 保护 alpha 功能时,如果用户删除了一个 pod 正在使用的 PVC,则该 PVC 不会被立即删除。PVC 的删除将被推迟,直到 PVC 不再被任何 pod 使用。 #可以看到,当 PVC 的状态为 Teminatiing 时,PVC 受到保护,Finalizers 列表中包含 kubernetes.io/pvc-protection: finalizers: - kubernetes.io/pvc-protection labels: app: mysql-min release: mysql-min name: mysql-min namespace: default spec: #PV 的访问模式(accessModes)有三种: #ReadWriteOnce(RWO):是最基本的方式,可读可写,但只支持被单个 Pod 挂载。 #ReadOnlyMany(ROX):可以以只读的方式被多个 Pod 挂载。 #ReadWriteMany(RWX):这种存储可以以读写的方式被多个 Pod 共享。 accessModes: - ReadWriteOnce resources: requests: storage: 10Gi storageClassName: mysql-min-storageclass-local #表示使用本地磁盘,实际生产中一般都使用nfs。 volumeMode: Filesystem volumeName: mysql-min-pv-local # status: # accessModes: # - ReadWriteOnce # capacity: # storage: 1Gi kind: List 可以看到,这个 PVC 没有任何特别的地方。唯一需要注意的是,它声明的 storageClassName 是 mysql-min-storageclass-local。所以,将来 Kubernetes 的 Volume Controller 看到这个 PVC 的时候,不会为它进行绑定操作。 最后,我们创建 Local Persistent Volume 资源文件: kubectl apply -f mysql-min-pv-local.yaml kubectl apply -f mysql-min-storageclass-local.yaml kubectl apply -f mysql-min-pvc.yaml 而后,调整 Deploy 并挂载卷: spec: containers: - image: centos/mysql-57-centos7:latest ... volumeMounts: - name: data mountPath: /var/lib/mysql volumes: - name: data persistentVolumeClaim: claimName: mysql-min ## 自定义配置文件 通过创建 configmap 并挂载到容器中,我们可自定义 MySQL 配置文件。如下所示,名为 mysql-config 的 cm 包含一个 my.cnf 文件: apiVersion: v1 kind: ConfigMap metadata: name: mysql-config data: my.cnf: | [mysqld] default_storage_engine=innodb skip_external_locking lower_case_table_names=1 skip_host_cache skip_name_resolve max_connections=2000 innodb_buffer_pool_size=8589934592 init_connect='SET collation_connection = utf8_unicode_ci' init_connect='SET NAMES utf8' character-set-server=utf8 collation-server=utf8_unicode_ci skip-character-set-client-handshake query_cache_type=0 innodb_flush_log_at_trx_commit = 0 sync_binlog = 0 query_cache_size = 104857600 slow_query_log =1 slow_query_log_file=/var/lib/mysql/slow-query.log log-error=/var/lib/mysql/mysql.err long_query_time = 0.02 table_open_cache_instances=16 table_open_cache = 6000 skip-grant-tables sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION 将 configmap 挂载到容器内: spec: containers: - image: centos/mysql-57-centos7:latest volumeMounts: - name: mysql-config mountPath: /etc/my.cnf.d/my.cnf subPath: my.cnf volumes: - name: mysql-config - name: mysql-config configMap: name: mysql-config ## 设置容器时区 最傻瓜也最方便的处理方式,设置宿主机时区和时间文件与容器的映射。 spec: containers: - image: centos/mysql-57-centos7:latest volumeMounts: - name: localtime readOnly: true mountPath: /etc/localtime volumes: - name: localtime hostPath: type: File path: /etc/localtime ## 加密敏感数据 用户密码等敏感数据以 Secret 加密保存,而后被 Deployment 通过 volume 挂载或环境变量引用。如本例,我们创建root、user用户,将用户的密码加密保存: apiVersion: v1 data: #将mysql数据库的所有user的password配置到secret,统一管理 mysql-password: YWRtaW4= mysql-root-password: OVplTmswRGdoSA== kind: Secret metadata: labels: app: mysql-min release: mysql-min name: mysql-min namespace: default #Secret有三种类型: #Opaque:base64编码格式的Secret,用来存储密码、密钥等;但数据也通过base64 –decode解码得到原始数据,所有加密性很弱。 #kubernetes.io/dockerconfigjson:用来存储私有docker registry的认证信息。 #kubernetes.io/service-account-token: 用于被serviceaccount引用。serviceaccout创建时Kubernetes会默认创建对应的secret。Pod如果使用了serviceaccount,对应的secret会自动挂载到Pod目录/run/secrets/ kubernetes.io/serviceaccount中。 type: Opaque Secret 创建完成后,我们将用户明文密码从 Deployment 去除,采用环境变量方式引用 Secret 数据,参见如下 Yaml 修改: root 用户及 MYSQL_USER 用户,其密码均通过 secretKeyRef 从 secret 获取。 spec: containers: - image: centos/mysql-57-centos7:latest name: mysql-min imagePullPolicy: IfNotPresent env: #password存储在secret中 - name: MYSQL_ROOT_PASSWORD valueFrom: secretKeyRef: key: mysql-root-password name: mysql-min - name: MYSQL_PASSWORD valueFrom: secretKeyRef: key: mysql-password name: mysql-min - name: MYSQL_USER value: zuozewei ## 容器健康检查 K8S 镜像控制器可通过 livenessProbe 判断容器是否异常,进而决定是否重建容器;而 Service 服务可通过 readinessProbe 判断容器服务是否正常,从而确保服务可用性。 本例配置的 livenessProbe 与 readinessProbe 是一样的,即连续 3 次查询数据库失败,则定义为异常。对 livenessProbe 与readinessProbe 详细用法,不在本文的讨论范围内,可参考 K8S 官方文档: - Configure Liveness and Readiness Probes - Pod Lifecycle spec: containers: image: centos/mysql-57-centos7:latest #kubelet 使用 liveness probe(存活探针)来确定何时重启容器。例如,当应用程序处于运行状态但无法做进一步操作,liveness 探针将捕获到 deadlock,重启处于该状态下的容器,使应用程序在存在 bug 的情况下依然能够继续运行下去 livenessProbe: exec: command: - /bin/sh - "-c" - MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" - mysql -h 127.0.0.1 -u root -e "SELECT 1" failureThreshold: 3 #探测成功后,最少连续探测失败多少次才被认定为失败。默认是 3。最小值是 1。 initialDelaySeconds: 30 #容器启动后第一次执行探测是需要等待多少秒。 periodSeconds: 10 #执行探测的频率。默认是10秒,最小1秒。 successThreshold: 1 #探测失败后,最少连续探测成功多少次才被认定为成功。默认是 1。对于 liveness 必须是 1。最小值是 1。 timeoutSeconds: 5 #探测超时时间。默认1秒,最小1秒。 #Kubelet 使用 readiness probe(就绪探针)来确定容器是否已经就绪可以接受流量。只有当 Pod 中的容器都处于就绪状态时 kubelet 才会认定该 Pod处于就绪状态。该信号的作用是控制哪些 Pod应该作为service的后端。如果 Pod 处于非就绪状态,那么它们将会被从 service 的 load balancer中移除。 readinessProbe: exec: command: - /bin/sh - "-c" - MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" - mysql -h 127.0.0.1 -u root -e "SELECT 1" failureThreshold: 3 initialDelaySeconds: 5 periodSeconds: 10 successThreshold: 1 timeoutSeconds: 1 ## 容器初始化 容器的一些初始化操作显然适合通过 InitContainer 来完成,这里的 initContainer 是为了保证在 POD 启动前,PV盘 要先行绑定成功,同时为了避免 MySQL 数据库目录内的 lost+found 目录被误认为是数据库,初始化容器中将其删除; #Init 容器支持应用容器的全部字段和特性,包括资源限制、数据卷和安全设置。 然而,Init 容器对资源请求和限制的处理稍有不同,在下面 资源 处有说明。 而且 Init 容器不支持 Readiness Probe,因为它们必须在 Pod 就绪之前运行完成。 #如果为一个 Pod 指定了多个 Init 容器,那些容器会按顺序一次运行一个。 每个 Init 容器必须运行成功,下一个才能够运行。 当所有的 Init 容器运行完成时,Kubernetes 初始化 Pod 并像平常一样运行应用容器。 #mysql这里的initContainer是为了保证在POD启动前,PV盘要先行绑定成功。 initContainers: - command: - rm - -fr - /var/lib/mysql/lost+found image: busybox:1.29.3 imagePullPolicy: IfNotPresent name: remove-lost-found resources: {} terminationMessagePath: /dev/termination-log terminationMessagePolicy: File volumeMounts: - mountPath: /var/lib/mysql name: data restartPolicy: Always #scheduler 是 kubernetes 的调度器,主要的任务是把定义的 pod 分配到集群的节点上。 schedulerName: default-scheduler securityContext: {} #如果您的Pod通常需要超过30秒才能关闭,请确保增加优雅终止宽限期。可以通过在Pod YAML中设置terminationGracePeriodSeconds选项来实现. #如果容器在优雅终止宽限期后仍在运行,则会发送SIGKILL信号并强制删除。与此同时,所有的Kubernetes对象也会被清除。 terminationGracePeriodSeconds: 30 #定义数据卷PVC,与PV匹配。 volumes: - name: data persistentVolumeClaim: claimName: mysql-min - name: mysql-config configMap: name: mysql-config - name: localtime hostPath: type: File path: /etc/localtime **注:文本有限,故删减一部分,具体内容请参考原文。** # 小结 Kubernetes 很多看起来比较“繁琐”的设计的主要目的,都是希望为开发者提供更多的“可扩展性”,给使用者带来更多的“稳定性”和“安全感”。这两个能力的高低,是衡量开源基础设施项目水平的重要标准。 示例中揉合 Kubernetes 多项技术,构建了一个复杂且可做生产使用的单实例数据库。 本文源码: - blog-example/Kubernetes/k8s-mysql-pv-local at master · zuozewei/blog-example · GitHub # 参考资料: [1]:《深入剖析Kubernetes》
-
@GetMapping("/downloadFile") public String downloadFile(@RequestParam Integer fId){ if (fileStoreService.getFileStoreByUserId(loginUser.getUserId()).getPermission() == 2){ logger.error("用户没有下载文件的权限!下载失败..."); return "redirect:/error401Page"; } //获取文件信息 MyFile myFile = myFileService.getFileByFileId(fId); String remotePath = myFile.getMyFilePath(); String fileName = myFile.getMyFileName()+myFile.getPostfix(); try { //去FTP上拉取 OutputStream os = new BufferedOutputStream(response.getOutputStream()); response.setCharacterEncoding("utf-8"); // 设置返回类型 response.setContentType("multipart/form-data"); // 文件名转码一下,不然会出现中文乱码 response.setHeader("Content-Disposition", "attachment;fileName=" + URLEncoder.encode(fileName, "UTF-8")); boolean flag = FtpUtil.downloadFile("/" + remotePath, fileName, os); if (flag) { myFileService.updateFile( MyFile.builder().myFileId(myFile.getMyFileId()).downloadTime(myFile.getDownloadTime() + 1).build()); os.flush(); os.close(); logger.info("文件下载成功!" + myFile); } } catch (Exception e) { e.printStackTrace(); } return "success"; } /** * @Description 删除文件 * @Author xw * @Date 23:14 2020/2/10 * @Param [fId, folder] * @return java.lang.String **/ @GetMapping("/deleteFile") public String deleteFile(@RequestParam Integer fId,Integer folder){ //获得文件信息 MyFile myFile = myFileService.getFileByFileId(fId); String remotePath = myFile.getMyFilePath(); String fileName = myFile.getMyFileName()+myFile.getPostfix(); //从FTP文件服务器上删除文件 boolean b = FtpUtil.deleteFile("/"+remotePath, fileName); if (b){ //删除成功,返回空间 fileStoreService.subSize(myFile.getFileStoreId(),Integer.valueOf(myFile.getSize())); //删除文件表对应的数据 myFileService.deleteByFileId(fId); } logger.info("删除文件成功!"+myFile); return "redirect:/files?fId="+folder; } /** * @Description 删除文件夹并清空文件 * @Author xw * @Date 15:22 2020/2/12 * @Param [fId] * @return java.lang.String **/ @GetMapping("/deleteFolder") public String deleteFolder(@RequestParam Integer fId){ FileFolder folder = fileFolderService.getFileFolderByFileFolderId(fId); //强制删除 deleteFolderF(folder); return folder.getParentFolderId() == 0?"redirect:/files":"redirect:/files?fId="+folder.getParentFolderId(); } /** * @Description 迭代删除文件夹里面的所有文件和子文件夹 * @Author xw * @Date 9:17 2020/2/29 * @Param [folder] * @return void **/ public void deleteFolderF(FileFolder folder){ //获得当前文件夹下的所有子文件夹 List<FileFolder> folders = fileFolderService.getFileFolderByParentFolderId(folder.getFileFolderId()); //删除当前文件夹的所有的文件 List<MyFile> files = myFileService.getFilesByParentFolderId(folder.getFileFolderId()); if (files.size()!=0){ for (int i = 0; i < files.size(); i++) { Integer fileId = files.get(i).getMyFileId(); boolean b = FtpUtil.deleteFile("/"+files.get(i).getMyFilePath(), files.get(i).getMyFileName() + files.get(i).getPostfix()); if (b){ myFileService.deleteByFileId(fileId); fileStoreService.subSize(folder.getFileStoreId(),Integer.valueOf(files.get(i).getSize())); } } } if (folders.size()!=0){ for (int i = 0; i < folders.size(); i++) { deleteFolderF(folders.get(i)); } } fileFolderService.deleteFileFolderById(folder.getFileFolderId()); } /** * @Description 添加文件夹 * @Author xw * @Date 23:16 2020/2/10 * @Param [folder, map] * @return java.lang.String **/ @PostMapping("/addFolder") public String addFolder(FileFolder folder,Map<String, Object> map) { //设置文件夹信息 folder.setFileStoreId(loginUser.getFileStoreId()); folder.setTime(new Date()); //获得当前目录下的所有文件夹,检查当前文件夹是否已经存在 List<FileFolder> fileFolders = null; if (folder.getParentFolderId() == 0){ //向用户根目录添加文件夹 fileFolders = fileFolderService.getRootFoldersByFileStoreId(loginUser.getFileStoreId()); }else{ //向用户的其他目录添加文件夹 fileFolders = fileFolderService.getFileFolderByParentFolderId(folder.getParentFolderId()); } for (int i = 0; i < fileFolders.size(); i++) { FileFolder fileFolder = fileFolders.get(i); if (fileFolder.getFileFolderName().equals(folder.getFileFolderName())){ logger.info("添加文件夹失败!文件夹已存在..."); return "redirect:/files?error=1&fId="+folder.getParentFolderId(); } } //向数据库写入数据 Integer integer = fileFolderService.addFileFolder(folder); logger.info("添加文件夹成功!"+folder); return "redirect:/files?fId="+folder.getParentFolderId(); } /** * @Description 重命名文件夹 * @Author xw * @Date 23:18 2020/2/10 * @Param [folder, map] * @return java.lang.String **/ @PostMapping("/updateFolder") public String updateFolder(FileFolder folder,Map<String, Object> map) { //获得文件夹的数据库信息 FileFolder fileFolder = fileFolderService.getFileFolderByFileFolderId(folder.getFileFolderId()); fileFolder.setFileFolderName(folder.getFileFolderName()); //获得当前目录下的所有文件夹,用于检查文件夹是否已经存在 List<FileFolder> fileFolders = fileFolderService.getFileFolderByParentFolderId(fileFolder.getParentFolderId()); for (int i = 0; i < fileFolders.size(); i++) { FileFolder folder1 = fileFolders.get(i); if (folder1.getFileFolderName().equals(folder.getFileFolderName()) && folder1.getFileFolderId() != folder.getFileFolderId()){ logger.info("重命名文件夹失败!文件夹已存在..."); return "redirect:/files?error=2&fId="+fileFolder.getParentFolderId(); } } //向数据库写入数据 Integer integer = fileFolderService.updateFileFolderById(fileFolder); logger.info("重命名文件夹成功!"+folder); return "redirect:/files?fId="+fileFolder.getParentFolderId(); } /** * @Description 重命名文件 * @Author xw * @Date 12:47 2020/2/12 * @Param [file, map] * @return java.lang.String **/ @PostMapping("/updateFileName") public String updateFileName(MyFile file,Map<String, Object> map) { MyFile myFile = myFileService.getFileByFileId(file.getMyFileId()); if (myFile != null){ String oldName = myFile.getMyFileName(); String newName = file.getMyFileName(); if (!oldName.equals(newName)){ boolean b = FtpUtil.reNameFile(myFile.getMyFilePath() + "/" + oldName+myFile.getPostfix(), myFile.getMyFilePath() + "/" + newName+myFile.getPostfix()); if (b){ Integer integer = myFileService.updateFile( MyFile.builder().myFileId(myFile.getMyFileId()).myFileName(newName).build()); if (integer == 1){ logger.info("修改文件名成功!原文件名:"+oldName+" 新文件名:"+newName); }else{ logger.error("修改文件名失败!原文件名:"+oldName+" 新文件名:"+newName); } } } } return "redirect:/files?fId="+myFile.getParentFolderId(); } /** * @Description 获得二维码 * @Author xw * @Date 15:20 2020/2/12 * @Param [id, url] * @return java.util.Map<java.lang.String,java.lang.Object> **/ @GetMapping("getQrCode") @ResponseBody public Map<String,Object> getQrCode(@RequestParam Integer id,@RequestParam String url){ Map<String,Object> map = new HashMap<>(); map.put("imgPath","https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=2654852821,3851565636&fm=26&gp=0.jpg"); if (id != null){ MyFile file = myFileService.getFileByFileId(id); if (file != null){ try { String path = request.getSession().getServletContext().getRealPath("/user_img/"); url = url+"/file/share?t="+ UUID.randomUUID().toString().substring(0,10) +"&f="+file.getMyFileId()+"&p="+file.getUploadTime().getTime()+""+file.getSize()+"&flag=1"; File targetFile = new File(path, ""); if (!targetFile.exists()) { targetFile.mkdirs(); } File f = new File(path, id + ".jpg"); if (!f.exists()){ //文件不存在,开始生成二维码并保存文件 OutputStream os = new FileOutputStream(f); QRCodeUtil.encode(url, "/static/img/logo.png", os, true); os.close(); } map.put("imgPath","user_img/"+id+".jpg"); map.put("url",url); } catch (Exception e) { e.printStackTrace(); } } } return map; } /** * @Description 分享文件 * @Author xw * @Date 14:23 2020/2/12 * @Param [fId] * @return void **/ @GetMapping("/file/share") public String shareFile(Integer f,String p,String t,Integer flag){ String fileNameTemp = ""; String remotePath = ""; String fileName = ""; Integer times = 0; if (flag == null || f == null || p == null || t == null){ logger.info("下载分享文件失败,参数错误"); return "redirect:/error400Page"; } if(flag == 1){ //获取文件信息 MyFile myFile = myFileService.getFileByFileId(f); if (myFile == null){ return "redirect:/error404Page"; } String pwd = myFile.getUploadTime().getTime()+""+myFile.getSize(); if (!pwd.equals(p)){ return "redirect:/error400Page"; } remotePath = myFile.getMyFilePath(); fileName = myFile.getMyFileName()+myFile.getPostfix(); }else if(flag == 2){ TempFile tempFile = tempFileService.queryById(f); if (tempFile == null){ return "redirect:/error404Page"; } Long test = tempFile.getUploadTime().getTime(); String pwd = tempFile.getSize(); if (!pwd.equals(p)){ return "redirect:/error400Page"; } remotePath = tempFile.getFilePath(); fileName = tempFile.getFileName(); }else { return "redirect:/error400Page"; } fileNameTemp = fileName; try { //解决下载文件时 中文文件名乱码问题 boolean isMSIE = isMSBrowser(request); if (isMSIE) { //IE浏览器的乱码问题解决 fileNameTemp = URLEncoder.encode(fileNameTemp, "UTF-8"); } else { //万能乱码问题解决 fileNameTemp = new String(fileNameTemp.getBytes("UTF-8"), "ISO-8859-1"); } //去FTP上拉取 OutputStream os = new BufferedOutputStream(response.getOutputStream()); response.setCharacterEncoding("utf-8"); // 设置返回类型 response.setContentType("multipart/form-data"); // 文件名转码一下,不然会出现中文乱码 response.setHeader("Content-Disposition", "attachment;fileName=" + fileNameTemp); if (FtpUtil.downloadFile("/" + remotePath, fileName, os)) { myFileService.updateFile( MyFile.builder().myFileId(f).downloadTime(times + 1).build()); os.flush(); os.close(); logger.info("文件下载成功!"); } } catch (Exception e) { e.printStackTrace(); } return "success"; } /** * @Description 根据文件的后缀名获得对应的类型 * @Author xw * @Date 23:20 2020/2/10 * @Param [type] * @return int 1:文本类型 2:图像类型 3:视频类型 4:音乐类型 5:其他类型 **/ public int getType(String type){ if (".chm".equals(type)||".txt".equals(type)||".xmind".equals(type)||".xlsx".equals(type)||".md".equals(type) ||".doc".equals(type)||".docx".equals(type)||".pptx".equals(type) ||".wps".equals(type)||".word".equals(type)||".html".equals(type)||".pdf".equals(type)){ return 1; }else if (".bmp".equals(type)||".gif".equals(type)||".jpg".equals(type)||".ico".equals(type)||".vsd".equals(type) ||".pic".equals(type)||".png".equals(type)||".jepg".equals(type)||".jpeg".equals(type)||".webp".equals(type) ||".svg".equals(type)){ return 2; } else if (".avi".equals(type)||".mov".equals(type)||".qt".equals(type) ||".asf".equals(type)||".rm".equals(type)||".navi".equals(type)||".wav".equals(type) ||".mp4".equals(type)||".mkv".equals(type)||".webm".equals(type)){ return 3; } else if (".mp3".equals(type)||".wma".equals(type)){ return 4; } else { return 5; } } /** * @Description 正则验证文件名是否合法 [汉字,字符,数字,下划线,英文句号,横线] * @Author xw * @Date 23:22 2020/2/10 * @Param [target] * @return boolean **/ public boolean checkTarget(String target) { final String format = "[^\\u4E00-\\u9FA5\\uF900-\\uFA2D\\w-_.]"; Pattern pattern = Pattern.compile(format); Matcher matcher = pattern.matcher(target); return !matcher.find(); } /** * @Description 判断当前浏览器是否为ie * @Author xw * @Date 22:39 2020/3/5 * @Param [request] * @return boolean **/ public static boolean isMSBrowser(HttpServletRequest request) { String[] IEBrowserSignals = {"MSIE", "Trident", "Edge"}; String userAgent = request.getHeader("User-Agent"); for (String signal : IEBrowserSignals) { if (userAgent.contains(signal)){ return true; } } return false; }原文链接:https://blog.csdn.net/m0_66863468/article/details/122853352
-
public String uploadTempFile(@RequestParam("file") MultipartFile file,String url) { session.setAttribute("imgPath","https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=2654852821,3851565636&fm=26&gp=0.jpg"); String name = file.getOriginalFilename().replaceAll(" ",""); if (!checkTarget(name)){ logger.error("临时文件上传失败!文件名不符合规范..."); session.setAttribute("msg", "上传失败!文件名不符合规范"); return "redirect:/temp-file"; } SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); String dateStr = format.format(new Date()); String path = "temp/"+dateStr +"/"+UUID.randomUUID(); try { if (FtpUtil.uploadFile("/"+path, name, file.getInputStream())){ //上传成功 logger.info("临时文件上传成功!"+name); String size = String.valueOf(file.getSize()); TempFile tempFile = TempFile.builder().fileName(name).filePath(path).size(size).uploadTime(new Date()).build(); if (tempFileService.insert(tempFile)) { try { String id = UUID.randomUUID().toString(); String p = request.getSession().getServletContext().getRealPath("/user_img/"); Long t = tempFile.getUploadTime().getTime(); url = url+"/file/share?t="+ UUID.randomUUID().toString().substring(0,10) +"&f="+tempFile.getFileId()+"&p="+size+"&flag=2"; File targetFile = new File(p, ""); if (!targetFile.exists()) { targetFile.mkdirs(); } File f = new File(p, id + ".jpg"); if (!f.exists()){ //文件不存在,开始生成二维码并保存文件 OutputStream os = new FileOutputStream(f); QRCodeUtil.encode(url, "/static/img/logo.png", os, true); os.close(); } //异步删除临时文件 tempFileService.deleteById(tempFile.getFileId()); session.setAttribute("imgPath","user_img/"+id+".jpg"); session.setAttribute("url",url); session.setAttribute("msg","上传成功,扫码/访问链接 即可下载!"); return "redirect:/temp-file"; } catch (Exception e) { e.printStackTrace(); } }else { logger.info("临时文件数据库写入失败!"+name); session.setAttribute("url","error"); session.setAttribute("msg", "服务器出错了,临时文件上传失败!"); } }else{ //上传失败 logger.info("临时文件上传失败!"+name); session.setAttribute("url","error"); session.setAttribute("msg", "服务器出错了,上传失败!"); } } catch (IOException e) { e.printStackTrace(); } return "redirect:/temp-file"; } /** * @Description 网盘的文件上传 * @Author xw * @Date 23:10 2020/2/10 * @Param [files] * @return java.util.Map<java.lang.String,java.lang.Object> **/ @PostMapping("/uploadFile") @ResponseBody public Map<String, Object> uploadFile(@RequestParam("file") MultipartFile files) { Map<String, Object> map = new HashMap<>(); if (fileStoreService.getFileStoreByUserId(loginUser.getUserId()).getPermission() != 0){ logger.error("用户没有上传文件的权限!上传失败..."); map.put("code", 499); return map; } FileStore store = fileStoreService.getFileStoreByUserId(loginUser.getUserId()); Integer folderId = Integer.valueOf(request.getHeader("id")); String name = files.getOriginalFilename().replaceAll(" ",""); //获取当前目录下的所有文件,用来判断是否已经存在 List<MyFile> myFiles = null; if (folderId == 0){ //当前目录为根目录 myFiles = myFileService.getRootFilesByFileStoreId(loginUser.getFileStoreId()); }else { //当前目录为其他目录 myFiles = myFileService.getFilesByParentFolderId(folderId); } for (int i = 0; i < myFiles.size(); i++) { if ((myFiles.get(i).getMyFileName()+myFiles.get(i).getPostfix()).equals(name)){ logger.error("当前文件已存在!上传失败..."); map.put("code", 501); return map; } } SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); String dateStr = format.format(new Date()); String path = loginUser.getUserId()+"/"+dateStr +"/"+folderId; if (!checkTarget(name)){ logger.error("上传失败!文件名不符合规范..."); map.put("code", 502); return map; } Integer sizeInt = Math.toIntExact(files.getSize() / 1024); //是否仓库放不下该文件 if(store.getCurrentSize()+sizeInt > store.getMaxSize()){ logger.error("上传失败!仓库已满。"); map.put("code", 503); return map; } //处理文件大小 String size = String.valueOf(files.getSize()/1024.0); int indexDot = size.lastIndexOf("."); size = size.substring(0,indexDot); int index = name.lastIndexOf("."); String tempName = name; String postfix = ""; int type = 4; if (index!=-1){ tempName = name.substring(index); name = name.substring(0,index); //获得文件类型 type = getType(tempName.toLowerCase()); postfix = tempName.toLowerCase(); } try { //提交到FTP服务器 boolean b = FtpUtil.uploadFile("/"+path, name + postfix, files.getInputStream()); if (b){ //上传成功 logger.info("文件上传成功!"+files.getOriginalFilename()); //向数据库文件表写入数据 myFileService.addFileByFileStoreId( MyFile.builder() .myFileName(name).fileStoreId(loginUser.getFileStoreId()).myFilePath(path) .downloadTime(0).uploadTime(new Date()).parentFolderId(folderId). size(Integer.valueOf(size)).type(type).postfix(postfix).build()); //更新仓库表的当前大小 fileStoreService.addSize(store.getFileStoreId(),Integer.valueOf(size)); try { Thread.sleep(5000); map.put("code", 200); } catch (InterruptedException e) { e.printStackTrace(); } }else{ logger.error("文件上传失败!"+files.getOriginalFilename()); map.put("code", 504); } } catch (IOException e) { e.printStackTrace(); } return map; } /** * @Description 网盘的文件下载 * @Author xw * @Date 23:13 2020/2/10 * @Param [fId] * @return void **/原文链接:https://blog.csdn.net/m0_66863468/article/details/122853352
-
一、项目简述功能:用户的邮箱注册、验证码验证以及用户登录。 不需要注册账号,也可以上传满足条件的临时文件,但是 只4小时内有效。 文件的管理,上传、下载、重命名、删除、查看统计数 据、分类管理等。 文件夹的管理,创建、删除、重命名。 文件的分享,支持通过链博口二维码的分享方式等等。二、项目运行环境配置: Jdk1.8 + Tomcat8.5 + mysql + Eclispe (IntelliJ IDEA,Eclispe,MyEclispe,Sts 都支持)项目技术: JSP +Springboot+ SpringMVC + MyBatis + ThymeLeaf + FTP+ JavaScript + JQuery + Ajax + maven等等管理员控制器:/** * @ClassName: AdminController * @Description: 管理员控制器 **/@Controllerpublic class AdminController extends BaseController { private Logger logger = LogUtils.getInstance(AdminController.class); /** * @Description 前往用户管理页面 * @Author xw * @Date 15:11 2020/3/10 * @Param [map] * @return java.lang.String **/ @GetMapping("/manages-users") public String manageUsers(Map<String,Object> map,Integer cur){ if (loginUser.getRole() == 1){ //用于无访问权限 logger.error("当前登录用户:"+loginUser.getUserName()+"无管理员权限!"); return "redirect:/error401Page"; } //获取全部的用户 Integer usersCount = userService.getUsersCount(); //获取当前查询的页数,如果为空,默认为0 cur = (cur == null || cur<0)?0:cur; //获得统计信息 FileStoreStatistics statistics = myFileService.getCountStatistics(loginUser.getFileStoreId()); //分页获得20个用户信息 Page<Object> page = PageHelper.startPage(cur, 20); List<UserToShow> users = userService.getUsers(); map.put("statistics", statistics); map.put("users", users); map.put("page", page); map.put("usersCount", usersCount); logger.info("用户管理域的内容:"+map); return "admin/manage-users"; } /** * @Description 修改用户的权限和最大容量 * @Author xw * @Date 18:20 2020/3/10 * @Param [uId, pre, size] * @return java.lang.String **/ @GetMapping("/updateStoreInfo") @ResponseBody public String updateStoreInfo(Integer uId,Integer pre,Integer size){ Integer integer = fileStoreService.updatePermission(uId, pre, size*1024); if (integer == 1) { //更新成功,返回200状态码 logger.info("修改用户"+userService.queryById(uId).getUserName()+":的权限和仓库大小成功!"); return "200"; }else { //更新失败,返回500状态码 logger.error("修改用户"+userService.queryById(uId).getUserName()+":的权限和仓库大小失败!"); return "500"; } } /** * @Description 删除用户 * @Author xw * @Date 18:44 2020/3/10 * @Param [uId, cur] * @return java.lang.String **/ @GetMapping("/deleteUser") public String deleteUser(Integer uId,Integer cur){ cur = (cur == null || cur < 0)?1:cur; User user = userService.queryById(uId); FileStore fileStore = fileStoreService.getFileStoreByUserId(uId); List<FileFolder> folders = fileFolderService.getRootFoldersByFileStoreId(fileStore.getFileStoreId()); //迭代删除文件夹 for (FileFolder f:folders) { deleteFolderF(f); } List<MyFile> files = myFileService.getRootFilesByFileStoreId(fileStore.getFileStoreId()); //删除该用户仓库根目录下的所有文件 for (MyFile f:files) { String remotePath = f.getMyFilePath(); String fileName = f.getMyFileName()+f.getPostfix(); //从FTP文件服务器上删除文件 boolean b = FtpUtil.deleteFile("/"+remotePath, fileName); if (b){ //删除成功,返回空间 fileStoreService.subSize(f.getFileStoreId(),Integer.valueOf(f.getSize())); //删除文件表对应的数据 myFileService.deleteByFileId(f.getMyFileId()); } logger.info("删除文件成功!"+f); } if (FtpUtil.deleteFolder("/" + uId)){ logger.info("清空FTP上该用户的文件成功"); }else { logger.error("清空FTP上该用户的文件失败"); } userService.deleteById(uId); fileStoreService.deleteById(fileStore.getFileStoreId()); return "redirect:/manages-users?cur="+cur; } /** * @Description 迭代删除文件夹里面的所有文件和子文件夹 * @Author xw * @Date 9:17 2020/2/29 * @Param [folder] * @return void **/ public void deleteFolderF(FileFolder folder){ //获得当前文件夹下的所有子文件夹 List<FileFolder> folders = fileFolderService.getFileFolderByParentFolderId(folder.getFileFolderId()); //删除当前文件夹的所有的文件 List<MyFile> files = myFileService.getFilesByParentFolderId(folder.getFileFolderId()); if (files.size()!=0){ for (int i = 0; i < files.size(); i++) { Integer fileId = files.get(i).getMyFileId(); boolean b = FtpUtil.deleteFile("/"+files.get(i).getMyFilePath(), files.get(i).getMyFileName() + files.get(i).getPostfix()); if (b){ myFileService.deleteByFileId(fileId); fileStoreService.subSize(folder.getFileStoreId(),Integer.valueOf(files.get(i).getSize())); } } } if (folders.size()!=0){ for (int i = 0; i < folders.size(); i++) { deleteFolderF(folders.get(i)); } } fileFolderService.deleteFileFolderById(folder.getFileFolderId()); }}登录控制器:/** * @Description 登录控制器 * @return **/@Controllerpublic class LoginController extends BaseController { private Logger logger = LogUtils.getInstance(LoginController.class); /** * @Description 免登陆用户入口,用于本地开发测试,上线运营为了安全请删除此方法 * @Author xw * @Date 15:17 2020/2/26 * @Param [] * @return java.lang.String **/ @GetMapping("/admin") public String adminLogin(){ User user = userService.getUserByOpenId("123456"); logger.info("使用免登陆方式登录成功!"+user); session.setAttribute("loginUser", user); return "redirect:/index"; } /** * 用于注册流程,用户名,密码,邮箱等校验工作由前端来完成 * * @param map 用于存储提示信息 * @author GGBOY * @date 2020/1/28 */ @PostMapping("/register") public String register(User user, String code, Map<String, Object> map) { String uCode = (String) session.getAttribute(user.getEmail() + "_code"); if (!code.equals(uCode)) { map.put("errorMsg", "验证码错误"); return "index"; } // 用户名去空格 user.setUserName(user.getUserName().trim()); user.setImagePath("https://p.qpic.cn/qqconnect/0/app_101851241_1582451550/100?max-age=2592000&t=0"); user.setRegisterTime(new Date()); user.setRole(1); if (userService.insert(user)) { FileStore store = FileStore.builder().userId(user.getUserId()).currentSize(0).build(); fileStoreService.addFileStore(store); user.setFileStoreId(store.getFileStoreId()); userService.update(user); logger.info("注册用户成功!当前注册用户" + user); logger.info("注册仓库成功!当前注册仓库" + store); } else { map.put("errorMsg", "服务器发生错误,注册失败"); return "index"; } session.removeAttribute(user.getEmail() + "_code"); session.setAttribute("loginUser", user); return "redirect:/index"; } /** * 用户登录 * @param map 存储提示信息 * @return java.lang.String * @author 莫提 * @date 2020/1/28 */ @PostMapping("/login") public String login(User user, Map<String, Object> map) { User userByEmail = userService.getUserByEmail(user.getEmail()); if (userByEmail != null && userByEmail.getPassword().equals(user.getPassword())) { session.setAttribute("loginUser", userByEmail); logger.info("登录成功!"+userByEmail); return "redirect:/index"; }else{ User user1 = userService.getUserByEmail(user.getEmail()); String errorMsg = user1 == null ? "该邮箱尚未注册" : "密码错误"; logger.info("登录失败!请确认邮箱和密码是否正确!"); //登录失败,将失败信息返回前端渲染 map.put("errorMsg", errorMsg); return "index"; } } /** * @return void * @Description 向注册邮箱发送验证码, 并验证邮箱是否已使用 * @Author xw * @Date 19:32 2020/1/29 * @Param [userName, email, password] **/ @ResponseBody @RequestMapping("/sendCode") public String sendCode(String userName, String email, String password) { User userByEmail = userService.getUserByEmail(email); if (userByEmail != null) { logger.error("发送验证码失败!邮箱已被注册!"); return "exitEmail"; } logger.info("开始发送邮件.../n" + "获取的到邮件发送对象为:" + mailSender); mailUtils = new MailUtils(mailSender); String code = "123456"; session.setAttribute(email + "_code", code); return "success"; } /** * @Description 请求QQ登录 * @Author xw * @Date 18:27 2020/2/25 * @Param [] * @return void **/ @GetMapping("/loginByQQ") public void login() { response.setContentType("text/html;charset=utf-8"); try { response.sendRedirect(new Oauth().getAuthorizeURL(request)); logger.info("请求QQ登录,开始跳转..."); } catch (QQConnectException | IOException e) { e.printStackTrace(); } } /** * @Description QQ登录回调地址 * @Author xw * @Date 18:27 2020/2/25 * @Param [] * @return java.lang.String **/ @GetMapping("/connection") public String connection() { try { AccessToken accessTokenObj = (new Oauth()).getAccessTokenByRequest(request); String accessToken = null, openID = null; long tokenExpireIn = 0L; if ("".equals(accessTokenObj.getAccessToken())) { logger.error("登录失败:没有获取到响应参数"); return "accessTokenObj=>" + accessTokenObj + "; accessToken" + accessTokenObj.getAccessToken(); } else { accessToken = accessTokenObj.getAccessToken(); tokenExpireIn = accessTokenObj.getExpireIn(); logger.error("accessToken" + accessToken); request.getSession().setAttribute("demo_access_token", accessToken); request.getSession().setAttribute("demo_token_expirein", String.valueOf(tokenExpireIn)); // 利用获取到的accessToken 去获取当前用的openid -------- start OpenID openIDObj = new OpenID(accessToken); openID = openIDObj.getUserOpenID(); UserInfo qzoneUserInfo = new UserInfo(accessToken, openID); UserInfoBean userInfoBean = qzoneUserInfo.getUserInfo(); if (userInfoBean.getRet() == 0) { logger.info("用户的OPEN_ID: " + openID); logger.info("用户的昵称: " + removeNonBmpUnicode(userInfoBean.getNickname())); logger.info("用户的头像URI: " + userInfoBean.getAvatar().getAvatarURL100()); //设置用户信息 User user = userService.getUserByOpenId(openID); if (user == null){ user = User.builder() .openId(openID).userName(removeNonBmpUnicode(userInfoBean.getNickname())) .imagePath(userInfoBean.getAvatar().getAvatarURL100()). registerTime(new Date()).build(); if (userService.insert(user)){ logger.info("注册用户成功!当前注册用户" + user); FileStore store = FileStore.builder().userId(user.getUserId()).build(); if (fileStoreService.addFileStore(store) == 1){ user.setFileStoreId(store.getFileStoreId()); userService.update(user); logger.info("注册仓库成功!当前注册仓库" + store); } } else { logger.error("注册用户失败!"); } }else { user.setUserName(removeNonBmpUnicode(userInfoBean.getNickname())); user.setImagePath(userInfoBean.getAvatar().getAvatarURL100()); userService.update(user); } logger.info("QQ用户登录成功!"+user); session.setAttribute("loginUser", user); return "redirect:/index"; } else { logger.error("很抱歉,我们没能正确获取到您的信息,原因是: " + userInfoBean.getMsg()); } } } catch (QQConnectException e) { } finally { logger.error("登录成功!"); } return "登录失败!请查看日志信息..."; } /** * @Description 处理掉QQ网名中的特殊表情 * @Author xw * @Date 18:26 2020/2/25 * @Param [str] * @return java.lang.String 返回处理之后的网名 **/ public String removeNonBmpUnicode(String str) { if (str == null) { return null; } str = str.replaceAll("[^\\u0000-\\uFFFF]", ""); if ("".equals(str)) { str = "($ _ $)"; } return str; } /** * @Description 退出登录,清空session * @Author xw * @Date 18:26 2020/2/25 * @Param [] * @return java.lang.String **/ @GetMapping("/logout") public String logout() { logger.info("用户退出登录!"); session.invalidate(); return "redirect:/"; } }文件仓库控制器:/** * @ClassName: FileStoreController * @Description: 文件仓库控制器 **/@Controllerpublic class FileStoreController extends BaseController { private Logger logger = LogUtils.getInstance(FileStoreController.class); /** * @Description 上传临时文件 * @Author xw * @Date 23:14 2020/3/9 * @Param [files] * @return void **/ @PostMapping("/uploadTempFile") 原文链接:https://blog.csdn.net/m0_66863468/article/details/122853352
上滑加载中
推荐直播
-
华为云码道Agent集成与鸿蒙实战2026/08/11 周二 19:00-21:00
王一男-华为云码道产品规划专家;李炎-华为云码道产品专家;彭江敏-华为云鸿蒙端云一体化开发专家
本次直播带你解读华为云码道7月份产品新特性、新功能。更有专家演示码道Agent Space × 钉钉机器集成实战,从0到1打通消息通道;码道鸿蒙端云一体化实战,快速搭建员工签到系统。
回顾中 -
华为云开发者AI素养直播课·第五期2026/09/04 周五 16:00-18:00
林华鼎-华为云AI开发者运营负责人;蒋春阳-华为云AI开发者案例开发专家
本期直播内容: AI工具体验营 · 第5-8课连讲。Agent-Team 多智能体协作完成毕业设计实践
回顾中 -
华为云开发者AI素养ClassRoom·第六期2026/09/08 周二 19:00-20:00
樊渊-2026华为软件挑战赛冠军
高手来了:看软挑高手解析二维排样问题—从工业难题到算法突破
回顾中
热门标签