MySQL数据安全经验分享
根据我的开发经验,MySQL的数据安全涉及以下几个情况:
- 访问权限
- SQL注入
- 数据加密
- 数据备份
环境准备
这里用Docker来搭建MySQL环境,准备一下docker-compose文件,因为后面我们要用到主从复制,所以这里提前做好配置:
- MySQL主A(master):mysqlMA
- MySQL从A(slave):mysqlSA
version: "3"
services:
mysqlMA:
image: mysql:5.7.20
network_mode: mynetwork
container_name: mysqlMA
restart: always
ports:
- 3306:3306
volumes:
- /etc/localtime:/etc/localtime
- /home/mycontainers/mysqlMA/data:/data
- /home/mycontainers/mysqlMA/mysql:/var/lib/mysql
- /home/mycontainers/mysqlMA/conf:/etc/mysql
environment:
- MYSQL_ROOT_PASSWORD=root
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --log-bin=/var/lib/mysql/mysql-bin --server-id=1 --binlog-format=ROW --expire_logs_days=7 --max_binlog_size=500M
mysqlSA:
image: mysql:5.7.20
network_mode: mynetwork
container_name: mysqlSA
restart: always
ports:
- 3308:3306
volumes:
- /etc/localtime:/etc/localtime
- /home/mycontainers/mysqlSA/data:/data
- /home/mycontainers/mysqlSA/mysql:/var/lib/mysql
- /home/mycontainers/mysqlSA/conf:/etc/mysql
environment:
- MYSQL_ROOT_PASSWORD=root
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --log-bin=/var/lib/mysql/mysql-bin --server-id=3 --binlog-format=ROW --expire_logs_days=7 --max_binlog_size=500M
启动命令:
docker-compose -f mysql.yml up
搭建好后,我们先用mysqlMA一个环境,mysqlSA后面用到再说。
访问权限
首先我们会有一个超级管理员账号:root/root,如果Web应用和数据库处于内网环境中,或许对访问权限的要求没那么高,但通常情况下环境没有那么封闭,数据库需要对外网开放,那么就需要对数据库的访问设限。
MySQL提供一套用户权限管理系统,我们可以创建不同的用户来分配各自访问MySQL的权限,命令如下:
注:方括号包裹中文,表示对该参数的描述,包括方括号,如[账号]。
登录MySQL服务器
# 直接登录
mysql -u[账号] -p[密码]
mysql -uroot -proot
# 指定IP和端口登录
mysql -h [IP] -p[端口] -u[账号] -p[密码]
mysql -h mysqlMA -P 3306 -uroot -proot
# 登录并执行一段SQL语句,然后自动退出登录
mysql -h [IP] -P [端口] -u[账号] -p[密码] mysql -e "show tables"
mysql -h mysqlMA -P 3306 -uroot -proot mysql -e "show tables"
用户及访问权限的CRUD
# 用户列表
mysql -h mysqlMA -P 3306 -uroot -proot mysql -e "SELECT * FROM user"
# 创建用户
# '%'是指通配符,如果为:192.168.0.%,那么IP为192.168.0.前缀的设备都可以访问
# 直接写'%',则表示所有的IP都可以访问
CREATE USER '[账号]'@'[来源IP]' IDENTIFIED BY '[密码]';
CREATE USER 'cc'@'%' IDENTIFIED BY '123456';
CREATE USER 'cc'@'localhost' IDENTIFIED BY '123456';
# 查看指定用户已有权限
SHOW GRANTS FOR '[账号]'@'[来源IP]';
SHOW GRANTS FOR 'cc'@'%';
# 查看当前登录用户已有权限
SHOW GRANTS;
# 授予权限
GRANT [权限1],[权限2],... ON [数据库名称].* TO '[账号]'@'[来源IP]';
# 这里给cc用户分配了对mysql所有表的增删改查权限,更多的权限类型可以看这里:http://c.biancheng.net/view/7502.html
GRANT SELECT,INSERT,DELETE,UPDATE ON mysql.* TO 'cc'@'%';
# 收回权限
REVOKE [权限1],[权限2],... ON mysql.* FROM '[账号]'@'[来源IP]';
REVOKE DELETE ON mysql.* FROM 'cc'@'%';
# 删除用户
DROP USER '[账号]';
DROP USER 'cc';
以上是设置用户访问权限的常用命令,MySQL的用户角色权限系统远不止于此,但更详细的权限说明不是本文重点,所以就不多说明了。
SQL注入
SQL注入要考虑两个层面:
-
Web应用接口层
这里与前端关系密切,接收参数后,转成SQL语句就跑去数据库请求数据了,所以在这里要做好参数检查、特殊字符转义、预编译等处理。避免出现漏洞导致数据泄露、丢失等风险。
-
写一个过滤器/拦截器/AOP,检测用户请求参数是否有非法字符
-
在Mybatis的XML中,用#号来处理传入字符
比如请求参数为:
{ "username": "admin", "password": "'';drop table if exists tb_user;" }
那么在Mybatis中,用${password}来接收参数的话,实际执行的SQL可能是这样的:
SELECT * FROM tb_user FROM username = 'admin' AND password = '';drop table if exists tb_user;
会导致我们的tb_user表被删掉了,这是一种极不安全的做法。
所以我们要尽量用#号来处理传入字符:#{password}:
SELECT * FROM tb_user FROM username = 'admin' AND password = ''';drop table if exists tb_user;'
当我们不得不用${xxx}时,最好提前对传入参数做参数格式严查。
-
-
数据库用户层
这里可以作为接口层防范的最后一层保障,假设接口层还是没有做好防护,那么对数据库用户的权限设置更小的粒度,进一步的保障数据安全,即:
- 各业务的账号只能访问各业务的数据库表,或做读写分离
- 根据业务账号赋予读写权限、表操作权限
数据加密
指保存加密过后的数据,比如用户登录传过来的密码,可以在几种情况下做加密:
- 前端加密后传输到后端
- 后端加密后保存到数据库
这样可以保证,万一数据库数据发生了泄露,那么至少敏感信息还是安全的。
加密算法
密码加密通常用的不是加密算法,而是散列算法,优点是速度快,安全性高,但是缺点是数据不可逆,不能逆推回来,所以有时候我们会采用可逆的加密算法。
可逆的加密算法有对称加密和非对称加密,在业务中根据需要选择即可。
传输加密
即数据通过HTTPS加密传输,可以确保数据传输中的信息都是可信的,没有被篡改的,这需要配置MySQL对SSL的支持,申请HTTPS证书等准备工作。
有时候MySQL和Web应用处于内网中,由Nginx配置SSL证书来转发访问,这就可以不多此一举的配置MySQL。
数据备份
数据备份算是数据安全的最重要的一部分了,很多中小企业在生产环境只部署了一个MySQL服务,这使得数据安全极为不可靠,倘若哪天出现服务器故障、后端业务异常等问题,就会导致的数据丢失。
所以数据备份十分重要,为此,我们需要做以下事情(注意是在另外一台服务器做的):
-
定期全面备份
比如每隔一周,对数据库内的数据进行一次全面备份,即所有数据的备份,数据量最大。
-
定期增量备份
比如每隔一天,对上一次增量备份到这一次增量备份中新增的数据进行备份,数据量最小。
-
主从复制
新增一台MySQL服务器作为从库,实时同步主库的数据,比起增量备份,这种方式实时性更高。
-
主主复制
新增一台MySQL服务器同样作为主库,那么就有两台主库,对任何一个主库的操作都会同步到另外一个主库中,相比起主从,从库最多用来读库,不能用来写库,所以可以用来做数据备份;而主主两台都可以写库,通常用来做数据库的高容灾解决方案。
先准备一下数据环境吧,创建一个test数据库和创建一个user表:
CREATE DATABASE mytest CHARSET=utf8mb4;
CREATE TABLE `user` (
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `mytest`.`user`(`username`, `password`) VALUES ('c1', '123456');
INSERT INTO `mytest`.`user`(`username`, `password`) VALUES ('c2', '123456');
INSERT INTO `mytest`.`user`(`username`, `password`) VALUES ('c3', '123456');
冷备份
冷备指关闭数据库,然后打包备份:
# 关闭数据库
docker stop mysql
# 到容器映射路径
cd /home/mycontainers/mysqlMA
ls
conf data mysql
# 打包压缩,备份mysql文件夹
tar zcf mysql_backup-$(date +%F).tar.gz mysql/
ls
conf data mysql mysql_backup-2023-02-16.tar.gz
# 解压缩,恢复数据库
tar -zxf mysql_backup-2023-02-16.tar.gz -C .
ls
conf data mysql mysql_backup-2023-02-16.tar.gz
这种方式是将MySQL的数据源文件夹进行打包压缩保存,在需要的时候解压缩并移动到MySQL容器映射的路径上即可实现数据库的恢复。
冷备需要关闭数据库
热备份
冷备份的问题是要关闭数据库,绝大多数情况下我们是不能关闭数据库的,所以要选热备份。
MySQL自带备份工具:mysqldump
进入MySQL容器内:
docker exec -it mysql bash
cd /data
对单个库进行完全备份
mysqldump -u[账号] -p[密码] [数据库名称] > /[备份路径]/[备份文件名].sql
mysqldump -uroot -proot mytest > /data/db.sql
另:或者在宿主机中直接执行,下面的指令同理:
docker exec mysql /bin/bash -c 'mysqldump -uroot -proot mytest > /data/db.sql'
对多个库进行完全备份
mysqldump -u[账号] -p[密码] --databases [数据库1] [数据库2] > /[备份路径]/[备份文件名].sql
mysqldump -uroot -proot --databases mytest mytest2 > /data/db.sql
对所有库进行完全备份
mysqldump -u[账号] -p[密码] --all-databases > /[备份路径]/[备份文件名].sql
mysqldump -uroot -proot --all-databases > /data/db.sql
对表进行完全备份
mysqldump -u[账号] -p[密码] [数据库名称] [表名] > /[备份路径]/[备份文件名].sql
mysqldump -uroot -proot mytest user > /data/db.sql
备份多张表
mysqldump -uroot -proot 数据库名 --tables 表名1 表名2 > 路径
mysqldump -u[账号] -p[密码] [数据库名称] --tables [表名1] [表名2] > /[备份路径]/[备份文件名].sql
mysqldump -uroot -proot mytest --tables user role > /data/db.sql
备份时排除指定表
mysqldump -u[账号] -p[密码] [数据库名称] --ignore-table=[数据库名称].[表名1] --ignore-table=[数据库名称].[表名2] -d > /[备份路径]/[备份文件名].sql
mysqldump -uroot -proot mytest --ignore-table=mytest.user --ignore-table=mytest.role -d > /data/db.sql
仅备份表结构
以上是进行完全备份,如果只想导出表结构,则在上面的指令中添加 “-d” 即可,如:
mysqldump -uroot -proot --databases mytest -d > /data/db.sql
此时只会备份结构,不会备份数据
数据恢复
上面用mysqldump导出来的数据,可以有两种命令来进行恢复:
- source命令
- mysql命令
source命令
登录到MySQL服务,用source命令执行.sql脚本恢复数据:
# shell
mysql -uroot -proot
# mysql
mysql> use mytest
mysql> source /data/db.sql;
mysql命令
mysql -uroot -proot < /data/db.sql
编写Shell备份脚本
先说明脚本的实现思路,然后下面给出脚本源码。
全量备份:
就是一次性备份全部数据,是一种十分安全靠谱的备份方案,数据库出现问题不用担心,直接恢复即可。
当然缺点十分明显,就是数据库随着使用会变得越来越庞大,全量备份耗时比较长。
脚本实现思路:
- 记录备份时刻(年月日-时分秒),作为文件夹名,如:full_20230221_162107
- 创建该文件夹
- 使用mysqldump将数据库内全部数据导出成SQL文件,并存放到备份文件夹
- 使用mysqldump导出时要添加--delete-master-logs指令,即全量备份后删除日志
- 全量备份文件体积大,所以建议进行压缩节省空间
增量备份:
增量备份保存的是上一次全量备份到当前时间变化的数据,这可以解决全量备份耗时长的问题,全量备份一周备一次,增量备份可以每天或每三天备一次。
- 获取上一次全量备份的文件夹,起个别名叫A
- 刷新binlog日志,如当前是mysql-bin.000001的话就会变成mysql-bin.000002
- 保存所有的binlog日志到文件夹A,跳过当前最新的日志
为什么要放到上一次全量备份的文件夹中?
因为这样可以将数据归类,数据恢复的时候先从全量备份开始,然后按照增量备份顺序逐一进行恢复。
自动恢复脚本:
有了上面的基础,自动恢复脚本就很好理解了。
- 获取上一次全量备份的文件夹,起个别名叫A
- 解压全量备份的压缩包,得到db.sql
- 恢复全量备份数据
- 逐一恢复增量备份数据
全量备份脚本:fullbackup.sh
#!/bin/bash
# 备份文件根目录
rootDir=/data/backup
# 用户名和密码
user=root
pwd=root
echo "======================="
echo "开始进行MySQL全量备份..."
time=`date +%Y%m%d_%H%M%S`
echo "当前时间为:" $time
backupDir=$rootDir/full_$time
echo "此次全量备份文件目录为:" $backupDir
sqlPath=$backupDir/db.sql
echo "SQL文件路径为:" $sqlPath
mkdir -p $backupDir
echo "创建全量备份文件夹"
# --quick, -q 用于转储大表,强制mysqldump从服务器一次一行的检索表中的行而不是检索所有行并在输出前缓存到内存中
# --events, -E 导出事件
# --all-databases, -A 导出所有数据库
# --flush-logs 导出前刷新binlog日志,很重要
# --delete-master-logs 备份后删除日志,很重要,便于增量备份
# --single-transaction 导出数据前提交一个BEGIN事务
mysqldump -u$user -p$pwd --quick --events --all-databases --flush-logs --delete-master-logs --single-transaction > $sqlPath
# 压缩节省空间
gzPath=${backupDir}/db.tar.gz
tar -czf $gzPath $sqlPath
# 注:解压命令:tar -xvf db.tar.gz --strip-components 3 其中3表示去掉父级目录
# 压缩完删除SQL文件
if [ -f $gzPath ];then
echo "备份文件压缩完成,正在删除备份文件..."
rm -f $sqlPath
else
echo "备份文件压缩失败,保留备份文件"
fi
echo "全量备份完成!"
增量备份脚本:partbackup.sh
#!/bin/bash
# 备份文件根目录
rootDir=/data/backup
# 用户名和密码
user=root
pwd=root
echo "======================="
echo "开始进行MySQL增量备份..."
time=`date +%Y%m%d_%H%M%S`
echo "当前时间为:" $time
# 获取最近一次的全量备份文件夹
# ls -lt 列出所有文件并按时间排序
# grep full_ 过滤出包含关键字的文件
# head -n 1 查看排第一的文件
# awk '{print $9} 打印第九字段,这里是文件名
lastFullDir=`ls $rootDir -lt | grep full_ | head -n 1 | awk '{print $9}'`
echo $lastFullDir
# binlog文件夹
binDir=/var/lib/mysql
# binlog索引文件,记录了binlog日志信息
binFile=/var/lib/mysql/mysql-bin.index
# 刷新log日志
mysqladmin -u$user -p$pwd flush-logs
# 获取刷新后当前最新的mysql-bin.000000*文件的个数
count=`wc -l $binFile |awk '{print $1}'`
echo "当前binlog序号为:" $count
nextNum=0
for file in `cat $binFile`
do
# 截取出实际文件名,去掉前缀的父级路径,得到如:mysql-bin.000001
base=`basename $file`
nextNum=`expr $nextNum + 1`
if [ $nextNum -eq $count ]
then
echo $base " 跳过"
else
dest=$rootDir/$lastFullDir/$base
# 判断是否存在
if test -e $dest
then
echo $base " 文件已存在"
else
cp $binDir/$base $dest
echo $base " 文件已备份到:" $dest
fi
fi
done
echo "增量备份完成!"
自动恢复脚本:autorecovery.sh
#!/bin/bash
# 备份文件根目录
rootDir=/data/backup
# 用户名和密码
user=root
pwd=root
echo "======================="
echo "开始进行MySQL数据恢复..."
time=`date +%Y%m%d_%H%M%S`
echo "当前时间为:" $time
# 获取最近一次的全量备份文件夹
# ls -lt 列出所有文件并按时间排序
# grep full_ 过滤出包含关键字的文件
# head -n 1 查看排第一的文件
# awk '{print $9} 打印第九字段,这里是文件名
lastFullDir=`ls $rootDir -lt | grep full_ | head -n 1 | awk '{print $9}'`
dest=$rootDir/$lastFullDir
echo "最新的全量备份文件夹:" $dest
# 解压全量备份压缩包
cd $dest
tar -xvf db.tar.gz --strip-components 3
# 恢复全量备份
mysql -u$user -p$pwd < db.sql
# 恢复增量备份
for f in `ls $1 | grep mysql-bin`
do
mysqlbinlog $f | mysql -u$user -p$pwd
done
echo "数据恢复完成!"
不足之处
这种方式有个问题是,上一次增量备份到数据损坏的期间内产生的数据是没有办法恢复的,为了解决这个问题,我们可以利用主从复制,即增加一台专门用于备份的MySQL服务器实时同步数据。
除此之外,还有一种方案是使用Canal服务去同步binlog变化数据,保存到ES或者Kafka中。
主从复制
主从复制是指有两台或以上MySQL服务器,一台主负责读写,另一台从则只负责读,这是一种不错的数据备份方案。
顺带科普一下,这有利于提高Web应用的访问效率,因为从数据库可以帮助主数据库分担一部分访问压力。
MySQL的复制类型
在开始之前,我们最好先了解一下MySQL的三种复制类型,即主从数据同步,同步的是什么样子的数据?
三种复制类型有:
-
语句复制(STATEMENT)
主服务器上执行了一条SQL语句,那么同步给从服务器上的也是一样的SQL语句
因为只同步一条小小的SQL,即便这条SQL是更新表内的所有数据,但仍然只是一条SQL,所以这种类型的同步效率很高。
缺点是假如语句是这样的:
UPDATE tb_user SET update_time = now();
这个now()函数是动态获取数据,所以会导致主从的数据不一致。
-
行复制(ROW)
主服务器上执行了一条SQL语句,新增或修改了一条数据,同步给从服务器的是数据的内容。
同步的是改变的内容,有多条数据被修改就会同步多条数据,所以这种类型效率较慢,但是解决了基于语句复制导致的数据不一致问题。
-
混合类型复制(MIXED)
默认采用语句复制,当发现语句复制无法精确复制时,则采用行复制。
基于上面两种类型,应该很好理解,就不说明了。
那么主从复制用什么模式呢?一般是不考虑语句复制的,数据不一致的问题通常都不能被容忍,所以可以选择ROW或者MIXED,然后如果在业务上有要求说要记录一条数据的前后变化,即能查询出某一条数据的变更历史,那么只能用ROW,因为MIXED有可能只记录了语句,而没有记录数据的变化内容。
但是用ROW的话不是会造成很大的磁盘占用吗?答案是当有这样的业务需求,那么就应该为此付出成本,并且现在磁盘的费用并不高,绝对是够用的。
主从复制配置
主服务器要准备以下事情(在my.cnf配置文件中):
-
开启binlog
[mysqld] log-bin=logbin
-
设置server-id,要保证唯一
[mysqld] server-id=1
本文的环境搭建是基于Docker,在上面的docker-compose中已经做好,可以上去看看参考下。
启动主服务后,通过命令查看binlog状态:
SHOW VARIABLES LIKE '%log_bin%';
log_bin为ON的话就说明开启成功了。
从服务器同理,也要开启binlog,不同的是server-id:
[mysqld]
server-id=2
切记各个MySQL服务的server-id要保证唯一,不然会出问题。
主机:创建账号并授权
从机要同步数据,当然要有一个账号可以登录,主服务器不可能把root账号暴露出去,所以得创建一个:
MySQL5.7:
# 进入主机MySQL服务
mysql -uroot -uroot
# 给从机MySQL-SA创建账号
CREATE USER 'sa'@'%' IDENTIFIED BY '123456';
# 赋予复制权限
GRANT REPLICATION SLAVE ON *.* TO 'sa'@'%';
# 看看效果
SHOW GRANTS FOR 'sa'@'%';
MySQL8.0:
CREATE USER 'sa'@'%' IDENTIFIED BY '123456';
GRANT REPLICATION SLAVE ON *.* TO 'sa'@'%';
# 此语句必须执行
ALTER USER 'sa'@'%' IDENTIFIED WITH mysql_native_password BY '123456';
FLUSH PRIVILEGES;
MySQL5.7和8.0的账户授权有点不一样,自行选择就好。
从机:配置需要复制的主机
配置从机前我们要拿到主机的一些信息:
- binlog文件名(file)
- 记录位置(position)
这些信息可以在主机上通过命令获取到:
SHOW MASTER STATUS;
为什么需要上面这些信息?
这里就要简单说明一下主从复制的原理了,主机开启binlog后,数据变更都会写入记录到binlog日志中,也就是mysql-bin.00000*文件,从机同步的就是这个binlog日志数据。
这个mysql-bin.00000*日志会随着程序运行产生越来越多的文件,序号从1、2、3、...到更多,每一个文件中很多条记录,从机同步数据当然要知道从哪个文件、从文件内哪一行位置开始同步,所以就需要先从主机上得到这些信息。
还有一点需要注意的是,在同步之前,从机还需要对主机的数据进行一次全量数据同步,然后加上binlog文件和记录位置,才能保证主机从机在同一起跑线,同步一致的数据。
全量数据同步:
可以参考上面编写的脚本同步到从机的数据库中,但要留意一点,上面写全量备份脚本的时候备份的是全部数据库,即--all-databases
,这包含了所有的账号信息,是不应该同步给从库的,一般是指定某几个业务数据库同步数据给从库。
全量备份脚本目的是保证数据安全,所以备份所有数据库;主从复制的目的如果也是数据安全,那么也可以同步所有数据库,但如果是其他目的,比如读写分离缓解主库压力,那么就不应该同步所有数据库,避免用户敏感信息的泄露。
从机配置:
先看看从机是否开启了binlog:
SHOW VARIABLES LIKE '%log_bin%';
连接主机:
CHANGE MASTER TO master_log_file='mysql-bin.000060',master_log_pos=3066257,master_host='mysqlMA',master_port=3306,master_user='sa', master_password='123456';
复制延迟:master_delay,单位秒
我们知道主从复制可以充当数据备份的功能,当主数据库损坏或停止服务时,还有从库的数据备份,但是有这样一种情况:主数据库误删了一部分数据,这个删除操作被实时的同步到了从库,并且这部分数据是在上一次增量备份之后产生的,那么这时候我们是很难进行恢复的。
所以在主从复制时可以增加一个参数master_delay,表示对主库传输过来的数据延迟一定的时间再进行同步,实现一个时间差,比如设置为1小时,那么主数据库误删数据后,马上到从库去看,在1小时内还是存在的。
CHANGE MASTER TO master_log_file='mysql-bin.000060',master_log_pos=3066257,master_host='mysqlMA',master_port=3306,master_user='sa', master_password='123456', master_delay=10;
然后重启SLAVE即可:
STOP SLAVE;
START SLAVE;
如果报初始化问题(initialize relay...),可以删除之前的relay log信息,然后重新执行CHANGE MASTER TO命令:
RESET SLAVE;
查看同步状态:
SHOW SLAVE STATUS\G;
如果这两个是YES,则说明主从复制配置成功:
SLAVE_IO_RUNNING: YES
SLAVE_SQL_Running: YES
测试:
这时候去主机上新增或修改一条数据,当看到从机也一并同步了,说明主从复制成功。
主主复制
我们实现了主从复制,对主机的操作可以实时同步到从机,但是现在有个问题,从机的操作没有同步到主机,所以当我们有这个需要,那么配置主主的步骤和主从其实是一样的:
- 登录从数据库
- 为主数据库同步创建一个账号:ma/123456
- 查看从数据库当前的binlog信息,记录下来
- 主数据库去CHANGE MASTER TO ...
- STOP SLAVE; START SLAVE;
这样我们再去试下从数据库修改数据,这时候主数据库也一并改变了。
同步数据前要保证两库之间的数据是一致的。
参考资料
转载自:https://juejin.cn/post/7202796308608073787