简介:这是 Mycat 1.6.7.1 在 CentOS7 下的 Linux 发行压缩包,面向需要搭建分布式数据库中间件的中高级开发与运维人员,用于解决大规模数据存储时的分库分表、读写分离与水平扩展问题。包体共95个文件,整体16.74MB,以42个 jar 依赖库为主,辅以16个 properties 配置、10个 xml 分片规则文件、4个 sh 启动脚本及少量动态库,目录结构清晰,便于按模块查找。内容覆盖 server.xml 全局配置、schema.xml 表结构与分片规则、数据源定义,以及哈希、范围、枚举等多种分片策略示例,并附有 ZooKeeper 相关配置、dbseq.sql 初始化脚本和启动/关闭脚本,可帮助读者在 CentOS7 上快速搭建具备读写分离和高可用能力的数据库集群。包内还提供典型分片算法、全局序列号生成及协调配置,便于理解分布式事务与数据路由的实现方式。目前已有494人学习,适合希望结合具体配置理解 Mycat 原理并落地生产环境的工程技术人员。
1. 这个 tar.gz 不是数据库安装包:它解压后是 MySQL 的“路由中枢”
第一次看到Mycat-server-1.6.7.1-release-20190213150257-linux.tar.gz这个名字,很多人以为它是个数据库安装包,解压完能跑一个 mysqld。实际不是。它解压出来没有数据库内核,有的是一套 Java 写的代理层:你给应用一个 8066 端口当 MySQL 连,Mycat 在后头把一条 SQL 按分片规则拆到多台真实 MySQL 上执行,再合并结果返回。1.6.7.1 是 Mycat 1.6 系列里被生产环境用得最多的一版,这个构建号对应 2019 年 2 月 13 日的 release 包。它要解决的问题很直接:单库写不下了、单表数据量上亿了、主从库想读写分离但不想改应用代码。适合正在用 MySQL、想先拿中间件做分库分表和读写分离、团队里没人写过 Proxy 的读者。下面从部署开始,一步步把它跑起来。
2. 部署前先过三道关:JDK 8、目录结构、最小启动
2.1 环境判断:为什么 1.6.7.1 只认 JDK 8
Mycat 1.6 系列基于 JDK 8 开发,tar.gz包里的启动脚本不会帮你装 Java,只会去找JAVA_HOME。如果你的服务器默认装了 OpenJDK 11 或 17,解压完直接mycat start,大概率秒退,日志里报UnsupportedClassVersionError或者类加载异常。这不是包坏了,是 1.6 的老代码依赖 JDK 8 的模块结构,高版本 JDK 把内部 API 封了。
先做三件事,确认环境再解压:
# 1. 确认当前 Java 版本,必须是 1.8.x java -version # 2. 确认 JAVA_HOME 指向 JDK 8 路径 echo $JAVA_HOME # 3. 如果机器上装了多个 JDK,编辑 /etc/profile 固定它 export JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk export PATH=$JAVA_HOME/bin:$PATH参数说明:java -version输出里出现1.8.0_xxx才是对的,只写openjdk version "11.x"就不行。JAVA_HOME是 Mycat 启动脚本bin/mycat读取的关键变量,它优先用这个路径找java可执行文件。实际部署时我习惯把这个 export 写进/etc/profile.d/mycat.sh,避免重启后环境变量丢失。
内存方面,默认配置里 JVM 堆开得比较大,conf/wrapper.conf里wrapper.java.initmemory和wrapper.java.maxmemory分别控制初始堆和最大堆。2C4G 的机器上我会把maxmemory调到 2048,initmemory保持 1024,太小会导致分片合并查询时频繁 GC,太大又会跟 MySQL 抢内存。
2.2 tar.gz 解压与目录结构:bin、conf、lib、logs 各自管什么
解压命令很简单,但目标目录要先规划好。我一般统一放到/opt/mycat,而不是散落在/root或/home下,方便后面做 systemd 托管和日志轮转。
# 创建目标目录 mkdir -p /opt/mycat # 解压到 /opt/mycat,注意 -C 参数指定目录 tar -zxvf Mycat-server-1.6.7.1-release-20190213150257-linux.tar.gz -C /opt/mycat # 解压后确认目录结构 ls -l /opt/mycattar -zxvf的四个参数:z代表通过 gzip 解压,x代表 extract,v是 verbose 打印解压过程,f后面必须跟文件名。如果你用tar -xf也能解,系统会自动识别格式,但在脚本里我习惯写全。
解压完你会看到五个目录,职责分得很清楚:
| 目录 | 作用 | 部署阶段关注度 |
|---|---|---|
bin | 启动/停止脚本mycat,还有init_zk_data等辅助脚本 | 高 |
conf | 全部配置文件:schema.xml、rule.xml、server.xml | 最高 |
lib | Mycat 自身依赖的 jar,以及 MySQL 连接驱动 | 高 |
logs | mycat.log和wrapper.log | 高 |
catlet | 自定义全局序列等扩展脚本目录 | 低 |
lib目录里默认带的 MySQL 驱动是 5.1.x 的版本,这点先记下,后面配置 MySQL 8 时这里会变成一个坑。conf里这三个文件决定了整个分片行为,第 3 章逐个拆开讲。
2.3 第一次启动:start 之后看这 3 个信号判断成败
配置一行没改的情况下,可以先把服务拉起来验证环境没问题。这一步如果起不来,后面配置改再多都是白搭。
# 启动 /opt/mycat/bin/mycat start # 查看进程状态 /opt/mycat/bin/mycat status # 确认端口监听:8066 是数据端口,9066 是管理端口 netstat -lntup | grep -E '8066|9066' # 查看启动日志,这一行最关键 tail -n 50 /opt/mycat/logs/wrapper.log逻辑说明:mycat start通过 Tanuki Wrapper 拉起 Java 进程,status返回running只代表进程在,不代表服务可用。判断启动成功要看三个信号:第一,wrapper.log里出现mycat server startup successfully字样;第二,netstat能看到8066和9066都在监听;第三,logs/mycat.log里没有ERROR级别的堆栈。
常见失败是启动后进程很快就没了,status显示not running。这时wrapper.log最后几行通常会给出原因,最高频的是Invalid or unsupported syntax或者找不到JAVA_HOME,前者说明你还装了其他 Java 版本,后者说明环境变量没生效。第一次启动别急着改配置,先把这三步跑通,后面才有排查的抓手。
3. 核心配置三件套:把分库分表写进 schema.xml、rule.xml、server.xml
3.1 先分清逻辑库、数据节点、物理库三层模型
Mycat 的配置之所以让新手晕,是因为它有三个“库”的概念。我在培训团队时用一个例子讲明白:应用连接的叫逻辑库,它不对应任何真实数据库,只是 Mycat 内存里的一个路由入口;逻辑库下面有逻辑表,逻辑表里每条数据真正存在哪里,由数据节点决定;数据节点指向某个 MySQL 实例上的物理库。
应用 -> 逻辑库(schema) -> 逻辑表(table) -> 数据节点(dataNode) -> 物理库(database)这个tar.gz包里买不到任何物理库,它只是把请求路由到你的 MySQL 上。所以部署 Mycat 之前,物理库必须先建好,分片表也要先在 MySQL 里建好,Mycat 不会帮你建表,它只负责把 SQLinsert路由到正确的物理表。理解这一点,后面排查“表不存在”“Invalid datasource”时能找到方向。
3.2 schema.xml 落地一个订单表拆两个物理分片
直接给一个可以抄作业的最小配置。场景是订单表按order_id取模拆到两个物理库:dn1和dn2,两台 MySQL 分别部署在不同的机器上。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mycat:schema SYSTEM "schema.dtd"> <mycat:schema xmlns:mycat="http://io.mycat/"> <!-- 逻辑库:应用连接的是这里 --> <schema name="mycat_order" checkSQLschema="true" sqlMaxLimit="100"> <!-- 逻辑表:order_info 拆到 dn1、dn2,按 mod-long 规则分片 --> <table name="order_info" dataNode="dn1,dn2" rule="mod-long" /> <!-- 全局表:region 每台物理库都放一份,避免跨库 join --> <table name="region" type="global" dataNode="dn1,dn2" /> </schema> <!-- 数据节点:dn1 指向 MySQL A 的 order_db 库 --> <dataNode name="dn1" dataHost="hostA" database="order_db" /> <!-- 数据节点:dn2 指向 MySQL B 的 order_db 库 --> <dataNode name="dn2" dataHost="hostB" database="order_db" /> <!-- 数据主机:hostA 连接 MySQL A --> <dataHost name="hostA" maxCon="200" minCon="20" balance="0" writeType="0" dbType="mysql" dbDriver="jdbc"> <heartbeat>select user()</heartbeat> <writeHost host="A1" url="jdbc:mysql://192.168.1.10:3306/order_db" user="mycat_user" password="Mycat@123"> </writeHost> </dataHost> <!-- 数据主机:hostB 连接 MySQL B --> <dataHost name="hostB" maxCon="200" minCon="20" balance="0" writeType="0" dbType="mysql" dbDriver="jdbc"> <heartbeat>select user()</heartbeat> <writeHost host="B1" url="jdbc:mysql://192.168.1.11:3306/order_db" user="mycat_user" password="Mycat@123"> </writeHost> </dataHost> </mycat:schema>逻辑说明:schema的name是应用连接时用的库名,比如jdbc:mysql://mycat-host:8066/mycat_order。checkSQLschema设置为true时,如果 SQL 里写成select * from mycat_order.order_info,Mycat 会自动把库名前缀剥掉再路由;设为false则会原样发给物理库,物理库就会报Table mycat_order.order_info doesn't exist。sqlMaxLimit=100是安全网,没有带 limit 的查询会自动追加,防止一次把几百 GB 的数据拉到前端。
table标签里字段含义:name是逻辑表名,物理库里的表名要跟它一致;dataNode用逗号分隔列出所有物理位置;rule指向rule.xml里定义的分片规则;type="global"表示全局表,每个分片都放一份完整数据,适合配置类表,查询时不会做跨库 join。
dataHost里每个连接池参数都要解释:maxCon是到该 MySQL 实例的最大连接数,minCon是启动时保持的最小连接数。balance=0代表不启用读写分离,这台主机上所有请求都走writeHost。writeType=0表示第一个 writeHost 是主节点。dbDriver=jdbc走 JDBC 连接,1.6.7.1 也支持native驱动,但生产里 JDBC 更稳。
3.3 rule.xml 分片算法选择:mod-long、枚举、一致性哈希
分片规则是 Mycat 的分水岭。选错了,数据分布不均,扩容和查询都难受。rule.xml里一个完整规则由tableRule和function两部分组成,tableRule定义走哪个列、用哪个函数,function定义函数类名和参数。
<!-- mod-long:按分片键取模 --> <tableRule name="mod-long"> <rule> <columns>order_id</columns> <algorithm>mod-long</algorithm> </rule> </tableRule> <function name="mod-long" class="io.mycat.route.function.PartitionByMod"> <property name="count">2</property> </function>逻辑说明:columns指定分片键,这里是order_id。algorithm指向function的name。PartitionByMod是取模算法,count=2表示把数据平均分到 2 个节点。这个算法的优点是实现简单、路由快;缺点是新增节点时存量数据得重新分布,不适合频繁扩容。
实际项目里我常用的分片算法有三种,适用场景完全不同:
| 算法 | class 类名 | 核心参数 | 适用场景 | 注意点 |
|---|---|---|---|---|
| mod-long 取模 | PartitionByMod | count节点数 | 订单、流水等增长均匀的数据 | 扩容要迁移数据 |
| 枚举分片 | PartitionByFileMap | mapFile映射文件 | 按地域、按业务线分类 | 映射文件里要设defaultNode,否则未知值报错 |
| 一致性哈希 | PartitionByMurmurHash | seed、count、virtualBucketTimes | 用户表、内容表 | 分布均匀、扩容平滑,但路由计算略慢 |
分享一个选型经验:订单、交易流水这类有明确递增主键且增长模型稳定的,用mod-long最省心;需要按省份或渠道隔离的场景,用sharding-by-intfile把枚举值写进映射文件,路由直观;如果业务未来有扩容预期,或者分片键枚举值不可控,直接用murmur一致性哈希,别在取模上硬扛。
3.4 server.xml 说清账号授权与两个端口:8066 与 9066
server.xml管理的是 Mycat 自己的登录账号,不是 MySQL 的账号。应用连 8066 时用的用户名密码由这里控制,而不是物理库的账号。新增一个只读账号和修改端口,操作如下:
<system> <!-- Mycat 数据端口:应用 SQL 走这里 --> <property name="serverPort">8066</property> <!-- Mycat 管理端口:运维命令走这里 --> <property name="managerPort">9066</property> </system> <user name="mycat_app"> <property name="password">App@123456</property> <property name="schemas">mycat_order</property> <property name="readOnly">true</property> </user> <user name="mycat_manage"> <property name="password">Manage@123456</property> <property name="schemas">mycat_order</property> </user>逻辑说明:serverPort和managerPort必须不能被其他进程占用,改完重启生效。user标签里name是登录名,password是 Mycat 认证密码,schemas指向schema.xml里定义的逻辑库名,多个库用逗号分隔。readOnly=true表示该账号只能执行查询,写入会报权限错误。
管理端口 9066 的用途很重要,后面验证分片路由全靠它:
-- 连接管理端口,查看数据节点状态 mysql -h127.0.0.1 -P9066 -umycat_manage -pManage@123456 -- 查看所有数据节点的连接状态 show @@datanode; -- 动态加载配置,不用重启服务 reload @@config_all;参数说明:reload @@config_all是运维最常用的命令,它会把schema.xml、rule.xml、server.xml重新加载一遍,过程里如果配置写错会加载失败并提示具体节点。需要强调的是,reload与重启不一样,它不会断开现有连接,生产环境里优先用 reload 而不是 restart。但这个命令不是百分百无痛,长事务和连接池中已建立的连接可能还持有旧 schema,所以重大变更我还是建议在低峰期做。
4. 避坑清单:MySQL 8 认证、JDK 大版本、端口冲突、时区偏差、漏带分片键
4.1 MySQL 8.x 连接报错:认证插件不兼容的三种解法
现象:Mycat 启动正常,mycat.log里出现Public Key Retrieval is not allowed或Communications link failure,应用层连接报Access denied for user 'mycat_user',但用 Navicat 连同一台 MySQL 却正常。
原因:MySQL 8 默认的认证插件是caching_sha2_password,而 Mycat 1.6.7.1 的lib目录里自带的驱动是 MySQL 5.1.x,不支持这个插件。驱动版本太老,密码传输协议对不上。
解决:三种路径任选。第一种,把 MySQL 账号的认证插件改回mysql_native_password,兼容性最好:
-- 在 MySQL 8 物理库执行,替换掉 YOUR_PASSWORD ALTER USER 'mycat_user'@'%' IDENTIFIED WITH mysql_native_password BY 'YOUR_PASSWORD'; FLUSH PRIVILEGES;第二种,替换 Mycat 的lib目录下连接驱动为 8.x 版本,并在dataHost的url末尾追加allowPublicKeyRetrieval=true。第三种,如果物理库是 5.7 且不打算升级,保持现状即可。生产上我倾向于第一种 + 第二种都做,因为团队后面总要有人用新驱动排查。
4.2 JDK 11+ 启动即退:1.6 系列的真实兼容边界
现象:mycat start命令执行后进程立刻消失,status显示not running,wrapper.log末尾报java.lang.UnsupportedClassVersionError或java.lang.NoClassDefFoundError。
原因:JDK 9 开始模块化,JDK 11 起移除了 Mycat 1.6 依赖的java.se相关内部 API,导致类初始化时找不到符号。这不是 Mycat 配置问题,是 JDK 版本越界。
解决:到/usr/lib/jvm下确认有没有java-1.8.0,没有就先装 OpenJDK 8,然后检查bin/mycat脚本开头是否硬编码了JAVA_HOME。我的做法是把JAVA_HOME显式写进启动脚本前的export,比只改/etc/profile可靠,因为 systemd 托管时未必加载 profile。
4.3 第二个实例起不来:8066/9066 端口被占的排查路径
现象:在一台机器上同时跑两套 Mycat,第二个实例start后进程在,但netstat里看不到 8066,应用连不上;或者直接报BindException: Address already in use。
原因:server.xml里没改端口,两个实例默认都绑定 8066 和 9066,后启动的实例必然绑定失败。Mycat 的日志对这个问题提示得很隐蔽,只会在wrapper.log里出现一行网络异常,容易被忽略。
解决:第二个实例在server.xml的<system>段里把serverPort改成8067、managerPort改成9067,然后重启。排查时先用lsof -i :8066确认占用进程是谁,不要盲目 kill,生产环境上 8066 可能就是你在用的另一个中间件。
4.4 时间差 8 小时:时区配置不在 Mycat 而在 dataHost URL
现象:应用通过 Mycat 插入订单时间,数据库里存的时间比NOW()差 8 小时;或者报The server time zone value '???ú???' is unrecognized。
原因:JDBC 驱动连接物理库时,会话时区没有指定。Mycat 转发的是 SQL 本身,它不做时间转换,时区问题出在dataHost的连接 URL 上。
解决:在schema.xml每个dataHost的url里追加时区参数:
<writeHost host="A1" url="jdbc:mysql://192.168.1.10:3306/order_db?serverTimezone=Asia/Shanghai&useSSL=false" user="mycat_user" password="Mycat@123" />注意 XML 里&要转义成&,否则schema.xml解析报错。改完执行reload @@config_all并清理物理库连接池。这是一条血泪经验:如果只调 MySQL 的global_time_zone而不同步改连接 URL,过段时间 MySQL 重启或主从切换后时区又会漂回去。
4.5 不带分片键的查询不报错:它会在所有分片扫一遍
现象:有一条 SQL 明明很慢,逻辑库数据也不大,但执行要几十秒。看日志发现 Mycat 把它发到了所有数据节点,每个节点返回全量数据再合并。
原因:Mycat 对于不带分片键的查询,策略是全节点路由,也就是说select * from order_info where status = 1会同时在 dn1、dn2 上执行。如果物理表没建好索引,这等于一次全表扫描乘以节点数。Mycat 不报错,因为它无法判断数据在哪,只能全发。
解决:分片键条件必须带上,这是应用层规范问题。如果业务确实经常用非分片键查询,常见做法是建一个按该字段分片的冗余表,或者把这类表设计成全局表。改配置只能缓解,根治要靠分片键设计。Mycat 日志里看到一条 SQL 被拆出多个 DATA_NODE 执行,先怀疑是不是漏了分片键。
5. 读写分离与高可用:一个 dataHost 撑起一主一从
5.1 balance 从 0 到 3:读写分离的四档姿势
如果分片只是把数据拆开,读写分离则是把读压力卸载到从库。dataHost里的balance参数控制了读写分离的开关和力度,常见配置:
<dataHost name="hostA" maxCon="200" minCon="20" balance="3" writeType="0" dbType="mysql" dbDriver="jdbc"> <heartbeat>select user()</heartbeat> <writeHost host="M1" url="jdbc:mysql://192.168.1.10:3306/order_db" user="mycat_user" password="Mycat@123"> <readHost host="S1" url="jdbc:mysql://192.168.1.12:3306/order_db" user="mycat_user" password="Mycat@123" /> </writeHost> </dataHost>逻辑说明:readHost嵌在writeHost内部,语义是“M1 的从库是 S1”。balance有四个档位:0表示不启用读写分离,所有请求走 writeHost;1表示读请求在 writeHost 和 readHost 之间轮流分发;2表示读请求只发 readHost,writeHost 只处理写;3表示读请求在所有节点间随机分发。生产环境用2最纯粹,主库专心写,从库专心读。
balance=1的坑在于主库同时承担读写,一旦读压上来主库的写入延迟会明显上升。我的建议是:一主一从结构用2,一主多从想要所有节点都分担读用3,除非有特殊原因否则别用1。
5.2 heartbeat 与 switchType:主库宕机后切换恢复
Mycat 对主从状态的判断靠heartbeat配置的心跳 SQL。select user()是最轻量的探活语句,它能确认连接存活,但判断不了主从复制是否健康。switchType控制切换到策略,1.6.7.1 里常用的是-1和1:
<dataHost name="hostA" maxCon="200" minCon="20" balance="2" writeType="0" switchType="1" dbType="mysql" dbDriver="jdbc"> <heartbeat>show slave status</heartbeat> <writeHost host="M1" url="jdbc:mysql://192.168.1.10:3306/order_db" user="mycat_user" password="Mycat@123"> <readHost host="S1" url="jdbc:mysql://192.168.1.12:3306/order_db" user="mycat_user" password="Mycat@123" /> </writeHost> </dataHost>switchType=-1意味着关闭自动切换,主库挂了 Mycat 只是把该节点标记为不可用,不会把写流量切到从库,适合人为管控切换的场景。switchType=1开启自动切换,但要注意:Mycat 切换的是 writeHost 角色,它会把原 writeHost 标记为只读,把原 readHost 提升为 writeHost。这个切换不是 MySQL 层面的主从切换,它只改了 Mycat 的路由表,所以物理库的主从复制必须提前用 MHA 或手动操作完成,Mycat 不会帮你处理从库的提升。
踩过的坑是show slave status做心跳时,如果从库没有配置主从复制,这条 SQL 会直接报错,导致 Mycat 误判节点不可用。首次部署读写分离时,先手动在从库执行一遍show slave status,确认有输出再启动 Mycat。
5.3 事务内读写粘连:一条连接从头走到尾
配置好读写分离后,有个现象会让新手困惑:明明balance=2,事务里先写后读,第二条读 SQL 却还是走了主库。这不是配置没生效,而是 Mycat 的事务连接粘连机制。
Mycat 在一个事务内部不会释放后端连接,也就是说事务中所有 SQL 都绑定在同一个 dataHost 的后端连接上。如果这个连接是 writeHost,那么整个事务里的读也会走 writeHost。直到事务提交或回滚,连接释放回连接池,后续的普通查询才会重新按balance分配。
这个设计是对的,它避免了“事务里先写后读,读却落到从库,读到旧数据”的一致性问题。所以应用侧要注意:只读业务不要包在事务里,否则读写分离对这类 SQL 完全无效。一个常见优化是,在注解里指定路由:
<!-- Mycat 注解:强制这条 SQL 走从库 --> /*#mycat:db_type=slave*/ select count(*) from order_info;逻辑说明:/*#mycat:db_type=slave*/是 Mycat 1.6 支持的 SQL 注解,执行这条 SQL 时会强制走从库,不参与balance策略。适合报表查询、统计分析这类不介意延迟的读。但注解要写在 SQL 最前面,且不能破坏 SQL 语义。实际项目里我不会滥用注解,只有确认延迟可接受时才加。
6. 上线前最后一步:用 explain、日志和 reload 验证路由结果
配置改完不等于分片生效,我上线前习惯用一个下午做系统性验证。最直接的工具是 Mycat 的 explain 命令,它能把一条 SQL 发给哪些数据节点显示出来:
-- 连接 8066 端口执行 explain,看路由结果 mysql -h127.0.0.1 -P8066 -umycat_app -pApp@123456 -Dmycat_order -e "explain select * from order_info where order_id = 100;"期望输出类似:
DATA_NODE=dn1, SQL=select * from order_info where order_id = 100说明这条 SQL 只路由到 dn1。如果DATA_NODE同时出现dn1,dn2,说明分片键条件没有生效,回到第 4.5 节的排查路径。另一个验证入口是logs/mycat.log,执行一条真实查询后搜索关键字route,能看到 SQL 被拆分后的完整描述。这个日志在生产环境要开INFO级别,DEBUG级别日志量太大,只建议在压测时临时开。
验证读写分离是否生效,先看show @@datanode确认节点状态,然后在主库执行show processlist,看是否有来自 Mycat 的查询。从库执行show processlist能看到读流量。动态加载配置用管理端口,改完schema.xml不必重启:
mysql -h127.0.0.1 -P9066 -umycat_manage -pManage@123456 -e "reload @@config_all;"如果配置有错,reload 会失败并提示哪个数据节点出错,不影响当前运行状态,这是 Mycat 给的后悔药。但我的习惯是改动前先备份一份:
cp /opt/mycat/conf/schema.xml /opt/mycat/conf/schema.xml.$(date +%Y%m%d%H%M%S)演练顺序我固定下来好几套了:先灌一批测试订单,确认按order_id均匀落到两个物理库;再开读写分离,观察主从库的请求比例;接着直接 kill 掉主库 MySQL 进程,看 Mycat 是否按switchType把写流量切走;恢复主库后再把角色切回来。这一套走完,分库分表和读写分离才算真的敢交给业务。最早我部署时图省事跳过演练,结果上线第二天主库宕机,从库没提升,业务只读不可写,那次的教训让我养成了每次变更都要走一遍切换演练的习惯。希望帮到你。
本文还有配套的精品资源,点击获取