2024年10月

大家好,我是 V 哥。使用EasyExcel进行大数据量导出时容易导致内存溢出,特别是在导出百万级别的数据时。你有遇到过这种情况吗,以下是V 哥整理的解决该问题的一些常见方法,分享给大家,欢迎一起讨论:

EasyExcel大数据量导出常见方法

1. 分批写入

  • EasyExcel支持分批写入数据,可以将数据分批加载到内存中,分批写入Excel文件,避免一次性将大量数据加载到内存中。
  • 示例代码
     String fileName = "large_data.xlsx";
     ExcelWriter excelWriter = EasyExcel.write(fileName).build();
     WriteSheet writeSheet = EasyExcel.writerSheet("Sheet1").build();

     // 假设每次写入10000条数据
     int batchSize = 10000;
     List<Data> dataList;
     int pageIndex = 0;
     do {
         // 分页获取数据
         dataList = getDataByPage(pageIndex++, batchSize);
         excelWriter.write(dataList, writeSheet);
     } while (dataList.size() == batchSize);

     // 关闭资源
     excelWriter.finish();

2. 设置合适的JVM内存

  • 针对大数据导出场景,可以尝试增大JVM的内存分配,例如:
     java -Xms512M -Xmx4G -jar yourApp.jar
  • 解释

    • -Xms512M
      :设置初始堆大小为512MB。
    • -Xmx4G
      :设置最大堆大小为4GB。

3. 减少数据对象的复杂性

  • 导出数据时,尽量简化数据对象,避免不必要的嵌套和多余字段的加载,以减少对象占用的内存空间。

4. 关闭自动列宽设置

  • EasyExcel的自动列宽功能会占用大量内存,特别是在数据量较大的情况下。关闭自动列宽可以节省内存。
  • 示例代码
     EasyExcel.write(fileName)
             .registerWriteHandler(new SimpleWriteHandler()) // 不使用自动列宽
             .sheet("Sheet1")
             .doWrite(dataList);

5. 使用Stream导出(适合大数据)

  • 利用
    OutputStream
    分批写入数据,减少内存消耗。通过
    BufferedOutputStream
    可以进一步提高性能。
  • 示例代码
     try (OutputStream out = new BufferedOutputStream(new FileOutputStream(fileName))) {
         ExcelWriter excelWriter = EasyExcel.write(out).build();
         WriteSheet writeSheet = EasyExcel.writerSheet("Sheet1").build();
         int pageIndex = 0;
         List<Data> dataList;
         do {
             dataList = getDataByPage(pageIndex++, batchSize);
             excelWriter.write(dataList, writeSheet);
         } while (dataList.size() == batchSize);
         excelWriter.finish();
     } catch (IOException e) {
         e.printStackTrace();
     }

6. 选择合适的数据导出工具

  • 如果数据量非常大,可以考虑切换到支持更高性能的导出工具(如Apache POI的
    SXSSFWorkbook
    ),适合导出百万级别数据量,但配置和使用会更复杂。

亮点来了,那要如何使用 POI 的 SXSSFWorkbook来导出百万级别的数据量呢?

Apache POI的SXSSFWorkbook 实现百万级别数据量的导出案例

使用Apache POI的
SXSSFWorkbook
可以处理大数据量的Excel导出,因为
SXSSFWorkbook
基于流式写入,不会将所有数据加载到内存中,而是使用临时文件进行缓存,这样可以显著减少内存消耗,适合百万级别数据的导出。下面我们来看一个完整的实现示例。

代码如下

import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;

import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class LargeDataExportExample {

    public static void main(String[] args) {
        // 文件输出路径
        String filePath = "vg_large_data_export.xlsx";
        
        // 导出百万级数据
        exportLargeData(filePath);
    }

    private static void exportLargeData(String filePath) {
        // 每次写入的批次大小
        final int batchSize = 10000;
        // 数据总条数
        final int totalRows = 1_000_000;

        // 创建SXSSFWorkbook对象,内存中只保留100行,超过的部分会写入临时文件
        SXSSFWorkbook workbook = new SXSSFWorkbook(100);
        workbook.setCompressTempFiles(true); // 启用临时文件压缩

        // 创建工作表
        Sheet sheet = workbook.createSheet("Large Data");

        // 创建标题行
        Row headerRow = sheet.createRow(0);
        String[] headers = {"ID", "Name", "Age"};
        for (int i = 0; i < headers.length; i++) {
            Cell cell = headerRow.createCell(i);
            cell.setCellValue(headers[i]);
        }

        int rowNum = 1; // 数据开始的行号

        try {
            // 按批次写入数据
            for (int i = 0; i < totalRows / batchSize; i++) {
                // 模拟获取每批数据
                List<Data> dataList = getDataBatch(rowNum, batchSize);
                
                // 将数据写入到Excel中
                for (Data data : dataList) {
                    Row row = sheet.createRow(rowNum++);
                    row.createCell(0).setCellValue(data.getId());
                    row.createCell(1).setCellValue(data.getName());
                    row.createCell(2).setCellValue(data.getAge());
                }

                // 处理完成一批数据后,可以选择清除缓存数据,防止内存溢出
                ((SXSSFSheet) sheet).flushRows(batchSize); // 清除已写的行缓存
            }

            // 将数据写入文件
            try (FileOutputStream fos = new FileOutputStream(filePath)) {
                workbook.write(fos);
            }
            System.out.println("数据导出完成:" + filePath);

        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 关闭workbook并删除临时文件
            workbook.dispose();
        }
    }

    /**
     * 模拟分页获取数据
     */
    private static List<Data> getDataBatch(int startId, int batchSize) {
        List<Data> dataList = new ArrayList<>(batchSize);
        for (int i = 0; i < batchSize; i++) {
            dataList.add(new Data(startId + i, "Name" + (startId + i), 20 + (startId + i) % 50));
        }
        return dataList;
    }

    // 数据类
    static class Data {
        private final int id;
        private final String name;
        private final int age;

        public Data(int id, String name, int age) {
            this.id = id;
            this.name = name;
            this.age = age;
        }

        public int getId() {
            return id;
        }

        public String getName() {
            return name;
        }

        public int getAge() {
            return age;
        }
    }
}

来解释一下代码

  1. SXSSFWorkbook

    SXSSFWorkbook(100)
    表示内存中最多保留100行数据,超过的部分会写入临时文件,节省内存。
  2. 批次处理
    :通过
    batchSize
    控制每批次写入的数据量,以减少内存消耗。
    totalRows
    设置为1,000,000表示导出100万条数据。
  3. 模拟数据生成

    getDataBatch
    方法模拟分页获取数据,每次返回一批数据。
  4. 清除缓存行
    :每次写入一批数据后,通过
    flushRows(batchSize)
    将缓存的行从内存中清除,以控制内存占用。
  5. 压缩临时文件

    workbook.setCompressTempFiles(true)
    启用临时文件压缩,进一步减少磁盘空间占用。

需要注意的事项

  • 临时文件
    :SXSSFWorkbook会在系统临时文件夹中生成临时文件,需要确保磁盘空间足够。
  • 资源释放
    :完成数据写入后需要调用
    workbook.dispose()
    以清理临时文件。
  • 性能优化
    :可根据机器内存调整
    batchSize

    SXSSFWorkbook
    缓存行数,避免频繁刷新和内存溢出。


一、写在开头

前几篇博文大概介绍了什么是网络编程,以及网络编程的实战作用,今日起,我们将针对里面涉及到的重要知识点,进行详细的梳理与学习!

在整个WEB编程中,有个应用层的协议是我们无法跳过的,那就是
HTTP
,一个
超文本传输协议
我们浏览网页的时候,它总是第一个出现,我们今天就来学习了解一下它。


二、HTTP


2.1 HTTP的定义

HTTP是应用层的一个重要协议,中文译为超文本传输协议,是基于TCP协议之上的,主要为WEB浏览器和WEB服务器通讯所设计,可传输超文本和多媒体内容,当我们使用浏览器浏览网页的时候,我们网页就是通过 HTTP/HTTPS 请求进行加载的。


2.2 HTTP 响应状态码

HTTP状态码是描述HTTP请求结果的一个特定码值,通过它我们可以快速定位到本次请求的问题出现在了哪里。

在这里插入图片描述
状态码中,基本上以阿拉伯数字1-5作为开头,分别代表不同含义,其中1XX我们很少看到,它表示服务端正在处理接收到的请求:

  1. 100 Continue :客户端可以继续请求。通常在客户端已发送请求的初始部分后使用,表示伺服器已接收请求的初步部分,客户端应继续发送其余部分。
  2. 101 Switching Protocols :伺服器正在切换到客户端请求的协议。这在客户端请求更改协议(如从 HTTP/1.1 切换到 HTTP/2)时使用。

2XX(成功状态码)

  1. 200(成功) 服务器已成功处理了请求。这个状态码对servlet是缺省的,如果没有调用setStatus方法的话,就会得到 200;
  2. 201 Created:请求被成功处理并且在服务端创建了一个或多个新的资源。例如,通过 POST 请求创建一个新的用户。
  3. 204(无内容) 服务器成功处理了请求,未返回任何内容;
  4. 205(重置内容) 服务器成功处理了请求,未返回任何内容,重置文档视图,如清除表单内容;
  5. 206(部分内容) 服务器成功处理了部分 GET 请求。

3XX(重定向状态码)

  1. 300(多种选择) 服务器根据请求可执行多种操作。服务器可根据请求者 来选择一项操作,或提供操作列表供其选择;
  2. 301(永久移动) 请求的网页已被永久移动到新位置。服务器返回此响应时,会自动将请求者转到新位置;
  3. 302(临时移动) 服务器目前正从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求。会自动将请求者转到新位置;
  4. 304(未修改) 自从上次请求后,请求的网页未被修改过,不会返回网页内容;
  5. 305(使用代理) 请求者只能使用指定的代理访问请求的网页。

4XX(客户端错误状态码)

  1. 400(错误请求) 服务器不理解请求的语法 ;
  2. 401(身份验证错误) 此页要求授权;
  3. 403(禁止) 服务器直接拒绝 HTTP 请求,不处理。一般用来针对非法请求;
  4. 404你请求的资源未在服务端找到。比如你请求某个用户的信息,服务端并没有找到指定的用户;
  5. 406(不接受) 无法使用请求的内容特性响应请求的网页;
  6. 408(请求超时) 服务器等候请求时发生超时;
  7. 414(请求的 URI 过长) 请求的 URI 过长,服务器无法处理。

5XX(服务器错误状态码)

  1. 500(服务器内部错误) 服务器遇到错误,无法完成请求;
  2. 503(服务不可用) 目前无法使用服务器(由于超载或进行停机维护)。通常,这只是一种暂时的状态;
  3. 504(网关超时) 服务器作为网关或代理,未及时从上游服务器接收请求;
  4. 505(HTTP 版本不受支持) 服务器不支持请求中所使用的 HTTP 协议版本。


2.2 HTTP请求报文

根据如下报文案例,我们看一下,其中①,②和③属于请求行;④属于请求头;⑤属于报文体。
在这里插入图片描述

  • ① 是请求方法,HTTP/1.1 定义的请求方法有8种:GET、POST、PUT、DELETE、PATCH、HEAD、OPTIONS、TRACE,最常的两种GET和POST;
  • ② 为请求对应的URL地址,它和报文头的Host属性组成完整的请求URL;
  • ③ 是协议名称及版本号;
  • ④ 是HTTP的报文头,报文头包含若干个属性,格式为“属性名:属性值”,服务端据此获取客户端的信息。
  • ⑤ 是报文体,它将一个页面表单中的组件值通过param1=value1&param2=value2的键值对形式编码成一个格式化串,它承载多个请求参数的数据。不但报文体可以传递请求参数,请求URL也可以通过类似于“/chapter15/user.html? param1=value1&param2=value2”的方式传递请求参数。


2.3 HTTP VS HTTPS

我们从下图四个方面,对比分析HTTP与HTTPS。
在这里插入图片描述

  • 端口号: HTTP的端口号是80,而HTTPS的端口号是443;
  • URL前缀: HTTP 的 URL 前缀是 http://,HTTPS 的 URL 前缀是 https://;
  • 安全性和资源消耗: HTTP是运行在TCP之上的协议,传输皆为明文,客户端和服务端无法验证对方身份,而HTTPS是运行在SSL/TLS之上的,传输内容经过了对称加密,并且堆成加密的秘钥,又在服务器端进行了非对称加密,相对HTTP安全很多,不过因为这一系列的操作,也让HTTPS耗费了更多的服务器资源;
  • SEO: 搜索引擎通常会更青睐使用 HTTPS 协议的网站,因为 HTTPS 能够提供更高的安全性和用户隐私保护。使用 HTTPS 协议的网站在搜索结果中可能会被优先显示,从而对 SEO 产生影响。


2.4 HTTP不同版本

自1996年5月公布的HTTP1.0版本开始,经历了几十年时间,HTTP已经诞生了1.0,1.1,2.0,3.0等诸多版本,顺势时代发展,不断向前进步!


2.4.1 HTTP1.0 VS HTTP1.1

  1. 连接方式 :HTTP1.0 为短连接,需要使用 keep-alive 参数建立长连接,HTTP1.1 默认支持keep-alive长连接。
  2. 状态响应码 :HTTP1.1在原有的基础上增加了很多的响应码,光是错误响应状态码就新增了 24 种,如100、204、409、410等。
  3. 缓存机制 :在 HTTP1.0 中主要使用 Header 里的 If-Modified-Since,Expires 来做为缓存判断的标准,HTTP1.1 则引入了更多的缓存控制策略例如 Entity tag,If-Unmodified-Since, If-Match, If-None-Match 等更多可供选择的缓存头来控制缓存策略。
  4. 带宽 :HTTP1.0无法请求部分对象内容,不能断点续传,在HTTP1.1在请求头引入了 range 头域,它允许只请求资源的某个部分,即返回码是 206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
  5. Host头处理 :HTTP1.1 引入了 Host 头字段,允许在同一 IP 地址上托管多个域名,从而支持虚拟主机的功能。而 HTTP1.0 没有 Host 头字段,无法实现虚拟主机。


2.4.2 HTTP1.1 VS HTTP2.0

  1. 多路复用
    :HTTP2.0在同一个连接中允许同时传输多个请求和响应,互不干扰。而HTTP1.1中则采用的是串行方式,每个请求和响应都需要一个连接来处理,由于浏览器为了资源损耗控制在了6-8个TCP连接数限制,这样HTPP1.1的处理速度大大受限。
    在这里插入图片描述

  2. 二进制帧
    :HTTP2.0 使用二进制帧进行数据传输,而 HTTP1.1 则使用文本格式的报文。二进制帧更加紧凑和高效,减少了传输的数据量和带宽消耗。

  3. 头部压缩
    :HTTP1.1 支持Body压缩,Header不支持压缩。HTTP2.0 支持对Header压缩,使用了专门为Header压缩而设计的 HPACK 算法,减少了网络开销。


2.4.3 HTTP2.0 VS HTTP3.0

  1. 传输协议 :HTTP2.0 是基于 TCP 协议实现的,HTTP3.0 新增了 QUIC(Quick UDP Internet Connections) 协议来实现可靠的传输,提供与 TLS/SSL 相当的安全性,具有较低的连接和传输延迟。你可以将 QUIC 看作是 UDP 的升级版本,在其基础上新增了很多功能比如加密、重传等等。HTTP3.0 之前名为 HTTP-over-QUIC,从这个名字中我们也可以发现,HTTP3.0 最大的改造就是使用了 QUIC。
  2. 建立连接 : HTTP2.0 需要经过经典的 TCP 三次握手过程(由于安全的 HTTPS 连接建立还需要 TLS 握手,共需要大约 3 个 RTT)。由于 QUIC 协议的特性(TLS 1.3,TLS 1.3 除了支持 1 个 RTT 的握手,还支持 0 个 RTT 的握手)连接建立仅需 0-RTT 或者 1-RTT。这意味着 QUIC 在最佳情况下不需要任何的额外往返时间就可以建立新连接。
  3. 头部压缩 :HTTP2.0 使用 HPACK 算法进行头部压缩,而 HTTP3.0 使用更高效的 QPACK 头压缩算法。
  4. 容错性 :HTTP3.0 具有更好的错误恢复机制,当出现丢包、延迟等网络问题时,可以更快地进行恢复和重传。而 HTTP2.0 则需要依赖于 TCP 的错误恢复机制。
  5. 安全性 :在 HTTP2.0 中,TLS 用于加密和认证整个 HTTP 会话,包括所有的 HTTP 头部和数据负载。TLS 的工作是在 TCP 层之上,它加密的是在 TCP 连接中传输的应用层的数据,并不会对 TCP 头部以及 TLS 记录层头部进行加密,所以在传输的过程中 TCP 头部可能会被攻击者篡改来干扰通信。而 HTTP3.0 的 QUIC 对整个数据包(包括报文头和报文体)进行了加密与认证处理,保障安全性。
  6. 连接迁移 :HTTP3.0 支持连接迁移,因为 QUIC 使用 64 位 ID 标识连接,只要 ID 不变就不会中断,网络环境改变时(如从 Wi-Fi 切换到移动数据)也能保持连接。而 TCP 连接是由(源 IP,源端口,目的 IP,目的端口)组成,这个四元组中一旦有一项值发生改变,这个连接也就不能用了。


三、总结

好啦,今天的HTTP学习就到这里啦,其实对于java开发工程师而言,对于HTPP的了解程度,到此也就结束了,但对于网络工程师来说,HTPP是一个至关重要的知识,需要更深层次的去探究,推荐看《图解 HTTP》这本书。

热点随笔:

·
精神股东浅谈博客园盈利的问题
(
互联网Fans
)
·
Awesome Tools,程序员常用高效实用工具、软件资源精选,办公效率提升利器!
(
追逐时光者
)
·
10月22日纯血鸿蒙正式版发布意味着什么?
(
威哥爱编程
)
·
WebStorm 和 Rider 现在对非商业用途免费
(
张善友
)
·
卧槽,WebStorm现在免费啦!
(
前端欧阳
)
·
C#实现信创国产Linux麦克风摄像头推流(源码,银河麒麟、统信UOS)
(
Linux音视频开发
)
·
推荐一款专为Nginx设计的图形化管理工具: Nginx UI!
(
狂师
)
·
C#/.NET/.NET Core技术前沿周刊 | 第 10 期(2024年10.14-10.20)
(
追逐时光者
)
·
自建互联网档案馆「GitHub 热点速览」
(
削微寒
)
·
《花100块做个摸鱼小网站! 》第八篇—增加词云组件和搜索组件
(
sum墨
)
·
.NET使用OllamaSharp实现大模型推理对话的简单演示
(
WeskyNet
)
·
你为什么不应该过度关注go语言的逃逸分析
(
apocelipes
)

热点新闻:

·
华为正式发布「纯血鸿蒙」!首个国产移动操作系统来了,融入系统级 AI
·
一天迭代一次?为了原生鸿蒙,这些应用都快卷疯了...
·
Claude接管人类电脑12小时:学会摸鱼,敲着敲着代码看风景去了
·
稚晖君玩了个大的:开源人形机器人全套图纸+代码
·
在月球上盖房子!“月壤砖”来了
·
原生鸿蒙独立时刻已至,“爬北坡”后再寻求出海
·
“无意的算法残酷”:困在系统里的外卖骑手如何“养系统”
·
骁龙8至尊版移动平台解析 + 体验:自研 Oryon CPU 不负所望
·
京东邀杨笠代言引热议,部分消费者集体炸锅退款抗议,京东致歉:后续与相关脱口秀演员无合作计划
·
蔡磊,已在准备后事,遗嘱也已写好,活不了几天,不想穿睡衣走
·
字节大模型遭入侵续:清华研究生率先揭露,入侵者已被辞退,公司已通知协会备案
·
极氪们竞逐伪需求,“车内吃火锅”的荒诞与现实

前言

  • Hey, 我是
    Immerse
  • 系列文章首发于
    【Immerse】
    ,更多内容请关注该网站
  • 转载说明:转载请注明原文出处及版权声明!

1. 理解 NPM 包的结构

1.1 package.json 文件:包的核心

package.json
文件是 NPM 包的中央配置,定义了包的各个方面,从基本元数据到复杂的发布配置。

{
    "name": "my-awesome-package",
    "version": "1.0.0",
    "description": "一个令人惊叹的包",
    "main": "./dist/index.js",
    "module": "./dist/index.mjs",
    "types": "./dist/index.d.ts",
    "files": ["dist"],
    "scripts": {
        "build": "tsup src/index.ts --format cjs,esm --dts",
        "test": "jest"
    },
    "keywords": ["awesome", "package"],
    "author": "Your Name <you@example.com>",
    "license": "MIT",
    "dependencies": {
        "lodash": "^4.17.21"
    },
    "devDependencies": {
        "typescript": "^4.5.5",
        "tsup": "^5.11.13",
        "jest": "^27.4.7"
    }
}

让我们详细解析一些关键字段:

  • name

    version
    :这两个字段组成了包在 NPM 注册表中的唯一标识符。
  • main

    module

    types
    :这些指定了不同模块系统和 TypeScript 支持的入口点。
  • files
    :这个数组指定了发布包时应该包含哪些文件和目录。
  • scripts
    :这些是常见任务(如构建和测试)的命令快捷方式。

1.2 理解包的入口点

现代 JavaScript 生态系统支持多种模块格式。您的包应该通过提供多个入口点来适应不同的环境。

  1. main
    :主要入口点,通常用于 CommonJS (CJS)模块。
  2. module
    :用于 ECMAScript (ESM)模块的入口点。
  3. browser
    :用于浏览器环境的入口点。
  4. types
    :TypeScript 类型声明的入口点。

以下是一个包结构的示例:

my-awesome-package/
├── src/
│   ├── index.ts
│   └── utils.ts
├── dist/
│   ├── index.js        (CJS构建)
│   ├── index.mjs       (ESM构建)
│   ├── index.d.ts      (TypeScript声明)
│   └── browser.js      (浏览器特定构建)
├── package.json
└── tsconfig.json

对应的
package.json
配置:

{
    "name": "my-awesome-package",
    "version": "1.0.0",
    "main": "./dist/index.js",
    "module": "./dist/index.mjs",
    "browser": "./dist/browser.js",
    "types": "./dist/index.d.ts",
    "exports": {
        ".": {
            "require": "./dist/index.js",
            "import": "./dist/index.mjs",
            "types": "./dist/index.d.ts"
        }
    }
}

2. 深入理解模块格式

2.1 CommonJS (CJS)

CommonJS 是 Node.js 的传统模块格式。它使用
require()
进行导入,使用
module.exports
进行导出。

// mathUtils.js
function add(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

module.exports = {
    add,
    subtract,
};

// main.js
const mathUtils = require('./mathUtils');
console.log(mathUtils.add(5, 3)); // 输出: 8

2.2 ECMAScript 模块 (ESM)

ESM 是 JavaScript 模块的现代标准,使用
import

export
语句。

// mathUtils.mjs
export function add(a, b) {
    return a + b;
}

export function subtract(a, b) {
    return a - b;
}

// main.mjs
import { add, subtract } from './mathUtils.mjs';
console.log(add(5, 3)); // 输出: 8

2.3 通用模块定义 (UMD)

UMD 是一种允许模块在多种环境(CommonJS、AMD、全局变量)中工作的模式。

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['exports'], factory);
    } else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
        // CommonJS
        factory(exports);
    } else {
        // 浏览器全局变量
        factory((root.mathUtils = {}));
    }
})(typeof self !== 'undefined' ? self : this, function (exports) {
    exports.add = function (a, b) {
        return a + b;
    };
    exports.subtract = function (a, b) {
        return a - b;
    };
});

3. 高级包优化技术

3.1 Tree Shaking 和副作用

Tree shaking 是现代打包工具用来消除死代码的技术。要使您的包可以进行 tree shaking:

  1. 使用 ES 模块
  2. 避免副作用

  3. package.json
    中使用
    "sideEffects"
    字段
{
    "name": "my-utils",
    "version": "1.0.0",
    "sideEffects": false
}

如果某些文件确实有副作用:

{
    "name": "my-utils",
    "version": "1.0.0",
    "sideEffects": ["./src/polyfills.js", "*.css"]
}

3.2 代码分割和动态导入

对于大型包,考虑使用代码分割,允许用户只导入他们需要的部分:

// heavyFunction.js
export function heavyFunction() {
    // ... 一些计算密集型操作
}

// main.js
async function doHeavyWork() {
    const { heavyFunction } = await import('./heavyFunction.js');
    heavyFunction();
}

3.3 条件导出

使用条件导出为不同的环境或导入条件提供不同的入口点:

{
    "name": "my-package",
    "exports": {
        ".": {
            "import": "./dist/index.mjs",
            "require": "./dist/index.cjs",
            "browser": "./dist/browser.js"
        },
        "./utils": {
            "import": "./dist/utils.mjs",
            "require": "./dist/utils.cjs"
        }
    }
}

4. 版本管理和发布

4.1 语义化版本控制 (SemVer)

语义化版本使用三部分版本号:主版本号.次版本号.修订号

  • 主版本号:进行不兼容的 API 更改时
  • 次版本号:以向后兼容的方式添加功能时
  • 修订号:进行向后兼容的 bug 修复时
npm version patch -m "版本更新到 %s - 修复文档中的拼写错误"
npm version minor -m "版本更新到 %s - 添加新的实用函数"
npm version major -m "版本更新到 %s - 更改API结构"

4.2 预发布版本

对于预发布版本,使用带连字符的标签:

  • latest
    : 最新线上版本
  • alpha
    : 内部测试版本
  • beta
    : 公开测试版本
  • rc
    : 发行候选版本
    • Tips: 可以将这些标识符添加到版本号中,同时也可以添加额外版本:如:
      1.0.0-alpha.0

      1.0.0-beta.1

      1.0.0-rc.1
npm version prerelease --preid=alpha
# 1.0.0 -> 1.0.1-alpha.0

npm version prerelease --preid=beta
# 1.0.1-alpha.0 -> 1.0.1-beta.0

npm version prerelease --preid=rc
# 1.0.1-beta.0 -> 1.0.1-rc.0

4.3 使用标签发布

使用标签发布不同版本或预发布版本:

npm publish --tag next
npm publish --tag beta

用户可以安装特定版本:

npm install my-package@next
npm install my-package@beta

5. 持续集成和部署 (CI/CD)

5.1 使用 GitHub Actions 进行自动发布

创建一个
.github/workflows/publish.yml
文件:

name: 发布包

on:
    release:
        types: [created]

jobs:
    build:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v2
            - uses: actions/setup-node@v2
              with:
                  node-version: '14'
                  registry-url: 'https://registry.npmjs.org'
            - run: npm ci
            - run: npm test
            - run: npm run build
            - run: npm publish
              env:
                  NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

    publish-gpr:
        needs: build
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v2
            - uses: actions/setup-node@v2
              with:
                  node-version: '14'
                  registry-url: 'https://npm.pkg.github.com'
            - run: npm ci
            - run: npm publish
              env:
                  NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}}

这个工作流程将在您创建新版本时自动将您的包发布到 NPM 和 GitHub Packages。

5.2 自动化版本更新

您可以在 CI/CD 管道中自动化版本更新。以下是使用 GitHub Action 的示例:

name: 更新版本

on:
    push:
        branches:
            - main

jobs:
    bump-version:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v2
              with:
                  fetch-depth: 0
            - uses: actions/setup-node@v2
              with:
                  node-version: '14'
            - name: 更新版本
              run: |
                  git config --local user.email "action@github.com"
                  git config --local user.name "GitHub Action"
                  npm version patch -m "更新版本到 %s [skip ci]"
            - name: 推送更改
              uses: ad-m/github-push-action@master
              with:
                  github_token: ${{ secrets.GITHUB_TOKEN }}
                  branch: ${{ github.ref }}

这个动作将在每次向主分支推送更改时自动更新包的修订版本号。

6. 包开发最佳实践

6.1 文档

良好的文档对于包的采用至关重要。考虑使用像 JSDoc 这样的工具进行内联文档:

/**
 * 将两个数字相加。
 * @param {number} a - 第一个数字。
 * @param {number} b - 第二个数字。
 * @returns {number} a和b的和。
 */
function add(a, b) {
    return a + b;
}

6.2 测试

使用像 Jest 这样的框架实现全面的测试:

// math.js
export function add(a, b) {
    return a + b;
}

// math.test.js
import { add } from './math';

test('1 + 2 应该等于 3', () => {
    expect(add(1, 2)).toBe(3);
});

6.3 代码检查和格式化

使用 ESLint 进行代码检查,使用 Prettier 进行代码格式化。以下是一个示例
.eslintrc.js

module.exports = {
    env: {
        browser: true,
        es2021: true,
        node: true,
    },
    extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
    parser: '@typescript-eslint/parser',
    parserOptions: {
        ecmaVersion: 12,
        sourceType: 'module',
    },
    plugins: ['@typescript-eslint'],
    rules: {
        // 在这里添加自定义规则
    },
};

以及一个
.prettierrc
文件:

{
    "singleQuote": true,
    "trailingComma": "es5",
    "tabWidth": 2,
    "semi": true,
    "printWidth": 100
}

info.jpg

在 MySQL 的日常管理过程中,大家或多或少会遇到权限认证相关的问题。

例如,本来能够正常执行的操作,可能在新增一个账号或授权后就突然失败了。

这种现象往往让人误以为是 bug,但很多时候,其实并不是。

下面,将通过两个案例来阐明 MySQL 权限认证中的具体优先原则,并在此基础上,分析以下问题:

  • 通过 DML 操作修改权限表后,为什么需要执行 FLUSH PRIVILEGES?
  • 权限表中记录的顺序是否会影响权限认证的结果?
  • 在通过 GRANT 或 REVOKE 修改权限后,是否需要 KILL 已有连接才能使新权限生效?

案例 1

  1. 首先,创建一个账号:
    create user u1@'%' identified by 'password1';
    ,此时,在实例本地通过
    mysql -h10.0.0.108 -uu1 -p'password1'
    可以登录实例。
  2. 接着,创建一个新账号:
    create user u1@'10.%' identified by 'password2';
    ,用户名不变,改变的只是主机名。使用之前的密码登录会报错,提示 Access denied,需使用 password2 登录。
  3. 继续创建一个新账号:
    create user u1@'10.0.0.0/255.255.255.0' identified by 'password3';
    ,此时,使用 password1、password2 登录会报错,登录密码只能指定为 password3。
  4. 继续创建一个新账号:
    create user u1@'10.0.0.0/24' identified by 'password4';
    ,使用其它密码会报错,登录密码只能指定为 password4。
  5. 继续创建一个新账号:
    create user u1@'10.0.0.108' identified by 'password5';
    ,使用其它密码会报错,登录密码只能指定为 password5。

现象就是每创建一个新的账号,之前的密码就失效了,只能使用新的密码来登录。

该案例适用于 MySQL 8.0 及以上版本。如果是在 MySQL 5.7 上测试,只有前三步有效。

案例 2

这个案例演示的是数据库库名中包含通配符的场景。

create user u2@'%' identified by '123456';
create database my_db;
create table my_db.t1(id int primary key);
insert into my_db.t1 values(1);
grant select on my_db.* to u2@'%';

# mysql -h127.0.0.1 -uu2 -p123456 -e 'select * from my_db.t1;'
+----+
| id |
+----+
|  1 |
+----+

最初的需求是为
my_db
数据库授予库级别的查询权限,因此通过上述方式进行了授权。

但实际上,库名中的
_
是个通配符,它能够匹配任意一个字符。因此,上面的 SELECT 权限不仅适用于
my_db
,同样也适用于
my1db

my2db
等名称相似的数据库。

鉴于之前的授权不够严谨,我在之后的授权中使用了转义符
\

_
进行了转义,目的是只针对
my_db
进行授权。没想到,授权完成后,再次执行之前的 SELECT 操作会报错。

grant insert on `my\_db`.* to u2@'%';

# mysql -h127.0.0.1 -uu2 -p123456 -e 'select * from my_db.t1;'
ERROR 1142 (42000) at line 1: SELECT command denied to user 'u2'@'127.0.0.1' for table 't1'

分析案例 1

MySQL 在接收到客户端连接后,首先会通过
cached_acl_users_for_name
获取与该用户名相关的 ACL(访问控制列表)用户列表。接着,MySQL 会遍历该列表,检查客户端的用户名和主机名(IP)是否与列表中的记录匹配。如果匹配,则直接退出循环,不再检查其它记录。

以案例 1 为例,u1 对应的用户列表包含 5 条记录:
u1@'%'

u1@'10.%'

u1@'10.0.0.0/255.255.255.0'

u1@'10.0.0.0/24'

u1@'10.0.0.108'
。实际上,这 5 条记录都能与客户端匹配,但代码的处理逻辑是,一旦找到匹配项,MySQL 就不会再检查其它记录,即使该匹配项的密码不正确。所以,用户列表中记录的顺序很关键。

// mysql-8.4.2/sql/auth/sql_authentication.cc
static bool find_mpvio_user(THD *thd, MPVIO_EXT *mpvio) {
  ...
  if (likely(acl_users)) {
    list = cached_acl_users_for_name(mpvio->auth_info.user_name);
  }
  if (list) {
    for (auto it = list->begin(); it != list->end(); ++it) {
      ACL_USER *acl_user_tmp = (*it);

      if ((!acl_user_tmp->user ||
           !strcmp(mpvio->auth_info.user_name, acl_user_tmp->user)) &&
          acl_user_tmp->host.compare_hostname(mpvio->host, mpvio->ip)) {
         ...
        break;
      }
    }
  }
  ...
}

下面,我们分析下 ACL 用户列表的生成逻辑,这个是在
rebuild_cached_acl_users_for_name
函数中实现的。

// mysql-8.4.2/sql/auth/sql_auth_cache.cc 
void rebuild_cached_acl_users_for_name(void) {
  ...
  // 遍历 acl_users,将每个 ACL_USER 对象根据用户名分组到 name_to_userlist 中。
  for (ACL_USER *acl_user = acl_users->begin(); acl_user != acl_users->end();
       ++acl_user) {
    std::string name = acl_user->user ? acl_user->user : "";
    (*name_to_userlist)[name].push_back(acl_user);

    // 匿名用户(即用户名为空的对象)会被单独添加到 anons 列表中。
    if (!name.compare("")) anons.push_back(acl_user);
  }

  // 遍历 name_to_userlist,将 anons 中的匿名用户添加到每个非匿名用户的 ACL 列表中。
  for (auto it = name_to_userlist->begin(); it != name_to_userlist->end();
       ++it) {
    std::string name = it->first;
    if (!name.compare("")) continue;

    auto *list = &it->second;
    for (auto it2 = anons.begin(); it2 != anons.end(); ++it2) {
      list->push_back(*it2);
    }
    // 对每个用户列表进行排序。
    list->sort(ACL_USER_compare());
  }
}

这个函数的功能比较简单,就是遍历 acl_users,将每个 ACL_USER 对象根据用户名分组到 name_to_userlist 中。

name_to_userlist 是一个哈希表,其键是用户名,值是一个列表,列表中存储所有拥有相同用户名的 ACL_USER 对象。

重点是最后一步,会对每个用户列表进行排序,这个排序直接影响了列表中 ACL_USER 对象的顺序。

排序命令中的
ACL_USER_compare()
是一个比较函数,用于对 ACL_USER 对象进行排序。

下面我们看看这个函数的实现细节。

// mysql-8.4.2/sql/auth/sql_auth_cache.cc
bool ACL_USER_compare::operator()(const ACL_USER &a, const ACL_USER &b) {
  if (a.host.ip != 0) {
    if (b.host.ip != 0) {
      /* Both elements have specified IPs. The one with the greater mask goes
       * first. */
      if (a.host.ip_mask_type != b.host.ip_mask_type)
        return a.host.ip_mask_type < b.host.ip_mask_type;

      if (a.host.ip_mask == b.host.ip_mask) return a.user > b.user;

      return a.host.ip_mask > b.host.ip_mask;
    }
    /* The element with the IP goes first. */
    return true;
  }

  /* The element with the IP goes first. */
  if (b.host.ip != 0) return false;

  /* None of the elements has IP defined. Use default comparison. */
  return a.sort > b.sort;
}

该函数的实现逻辑如下:

  1. 如果两个对象都指定了 IP 地址(host.ip != 0),则首先比较掩码类型(ip_mask_type),其次是掩码值(ip_mask)。如果掩码值相等,则会比较用户名(user)。
  2. 如果只有一个对象指定了 IP 地址,则该对象应该排在前面。
  3. 如果两个对象都没有指定 IP 地址,则比较它们的排序值(sort)。

ip_mask_type 是一个
enum_ip_mask_type
枚举类型的变量,用于指定当前 ACL 用户的 IP 掩码类型。

enum enum_ip_mask_type {
  ip_mask_type_implicit,
  ip_mask_type_cidr,
  ip_mask_type_subnet
};

其中:

  • ip_mask_type_implicit:只指定了 IP 地址,没有掩码。案例 1 中的
    10.0.0.108
    属于这个类型。
  • ip_mask_type_cidr:以 CIDR 形式指定了 IP 地址和掩码。案例 1 中的
    10.0.0.0/24
    属于这个类型。
  • ip_mask_type_subnet:以子网掩码的形式指定了 IP 地址和掩码。案例 1 中的
    10.0.0.0/255.255.255.0
    属于这类型。

由于在初始化 ACL_USER 对象时,ip_mask_type 的默认值为 ip_mask_type_implicit,所以
u1@'%'

u1@'10.%'
这两个对象的 IP 掩码类型也是 ip_mask_type_implicit。只不过这两个对象没有指定 IP 地址,所以他们的排名比较靠后。

基于上述分析,这些对象在列表中的顺序如下:

  • u1@'10.0.0.108'
  • u1@'10.0.0.0/24'
  • u1@'10.0.0.0/255.255.255.0'
  • u1@'%',u1@'10.%'

虽然
10.0.0.0/24

10.0.0.0/255.255.255.0
表示的是同一个网络范围,但由于
10.0.0.0/24
的类型为 ip_mask_type_cidr,而
10.0.0.0/255.255.255.0
的类型为 ip_mask_type_subnet,因此
u1@'10.0.0.0/24'
会排在
u1@'10.0.0.0/255.255.255.0'
前面。

u1@'%' 和 u1@'10.%' 会排在最后,至于它们之间的先后顺序,则由它们的排序值(sort)决定。

ACL_USER 对象的排序值是通过
get_sort
函数获取的。

user.sort = get_sort(2, user.host.get_host(), user.user);

该函数会根据传入的字符串(IP和用户名)的内容(是否包含通配符,以及通配符出现的位置)来计算排序权重。简单来说,通配符在字符串中出现得越晚,排序值越高。

所以,案例 1 中的 5 个对象在列表中的顺序如下:

  • u1@'10.0.0.108'
  • u1@'10.0.0.0/24'
  • u1@'10.0.0.0/255.255.255.0'
  • u1@'10.%'
  • u1@'%'

无论是新增还是删除账号时,都会调用
rebuild_cached_acl_users_for_name
来重建 name_to_userlist。

这就是为什么,在案例 1 中,当新增一个主机名更具体的账号后,再使用之前的密码登录就会失败,只能使用新设置的密码。这个测试其实很典型地反映了 MySQL 权限认证中的具体优先原则。

分析案例 2

在执行
select * from my_db.t1
时,MySQL 首先会检查该用户是否拥有全局级别的 SELECT 权限。如果没有,则会进一步检查该用户库级别的权限。

获取用户库级别的权限是在
acl_get
函数中实现的。

// mysql-8.4.2/sql/auth/sql_auth_cache.cc
Access_bitmask acl_get(THD *thd, const char *host, const char *ip,
                       const char *user, const char *db, bool db_is_pattern) {
  Access_bitmask host_access = ~(Access_bitmask)0, db_access = 0;
  ...
  if (!db_is_pattern) {
    // 首先在 db_cache 中查找用户库级别的权限。如果找到,则直接返回该权限。
    const auto it = db_cache.find(std::string(key, key_length));
    if (it != db_cache.end()) {
      db_access = it->second->access;
      DBUG_PRINT("exit", ("access: 0x%" PRIx32, db_access));
      return db_access;
    }
  }
  // 如果未在缓存中找到权限,则遍历 acl_dbs。
  for (ACL_DB *acl_db = acl_dbs->begin(); acl_db != acl_dbs->end(); ++acl_db) {
    // 检查当前条目是否与客户端的用户、IP匹配。
    if (!acl_db->user || !strcmp(user, acl_db->user)) {
      if (acl_db->host.compare_hostname(host, ip)) {
        // 检查库名是否匹配。
        if (!acl_db->db ||
            (db &&
             (mysqld_partial_revokes()
                  ? (!strcmp(db, acl_db->db))
                  : (!wild_compare(db, strlen(db), acl_db->db,
                                   strlen(acl_db->db), db_is_pattern))))) {
          db_access = acl_db->access;
          if (acl_db->host.get_host()) goto exit;  // Fully specified. Take it
          break;                                   /* purecov: tested */
        }
      }
    }
  }
  if (!db_access) goto exit;  // Can't be better

exit:
    ...
    // 将新权限条目插入 db_cache 以便后续能够快速查询。
    insert_entry_in_db_cache(thd, entry);
  }
  DBUG_PRINT("exit", ("access: 0x%" PRIx32, db_access & host_access));
  return db_access & host_access;
}

函数的具体实现如下:

  1. 首先在 db_cache 中查找用户库级别的权限。如果找到,则直接返回该权限。

    db_cache 是一个字典,用于缓存用户库级别的权限。其键由客户端 IP、用户名和要访问的数据库名(以
    \0
    分隔)组成,例如案例 2 中的键是
    127.0.0.1\0u2\0my_db
    ,值是对应的库级别权限信息。通过这个缓存,MySQL 能够快速查找用户对特定数据库的访问权限,而无需每次都遍历 acl_dbs。

    acl_dbs 是一个数组,用于存储用户库级别的权限,这些权限的信息来自于
    mysql.db
    表。

  2. 如果未在缓存中找到权限,则遍历 acl_dbs。

    检查当前条目是否与客户端的用户、IP 匹配。如果匹配,则进一步判断库名是否匹配。

    如果参数
    partial_revokes
    设置为 ON,则会直接比较库名是否相等;如果为 OFF,则支持使用通配符来判断库名是否匹配。

  3. 将新权限条目插入 db_cache 以便后续能够快速查询。

在案例 2 中,第一次 SELECT 查询成功,用户的库级别权限会缓存到 db_cache 中。理论上,第二次查询应该也没问题,但却报错了。

为什么会报错呢?

实际上,在执行
grant insert on `my\_db`.* to u2@'%'
时,db_cache 会被清空,并且新增的权限也会插入到 acl_dbs 中。

插入操作是在
acl_insert_db
中实现的。

// mysql-8.4.2/sql/auth/sql_auth_cache.cc
void acl_insert_db(const char *user, const char *host, const char *db,
                   Access_bitmask privileges) {
  ACL_DB acl_db;
  assert(assert_acl_cache_write_lock(current_thd));
  acl_db.set_user(&global_acl_memory, user);
  acl_db.set_host(&global_acl_memory, host);
  acl_db.db = strdup_root(&global_acl_memory, db);
  acl_db.access = privileges;
  acl_db.sort = get_sort(3, acl_db.host.get_host(), acl_db.db, acl_db.user);
  auto upper_bound =
      std::upper_bound(acl_dbs->begin(), acl_dbs->end(), acl_db, ACL_compare());
  acl_dbs->insert(upper_bound, acl_db);
}

可以看到,在插入之前,会先通过 get_sort 获取 ACL_DB 对象的排序值。然后,使用
std::upper_bound
在 acl_dbs 中找到 ACL_DB 的插入位置。
std::upper_bound
会根据
ACL_compare()
的规则进行排序比较,以确保新元素插入后整个数组依然有序。

ACL_compare::operator()
的实现逻辑与
ACL_USER_compare::operator()
类似,当两个对象 IP 都一样的情况下,实际上比较的就是排序值(sort)。

// mysql-8.4.2/sql/auth/sql_auth_cache.cc
bool ACL_compare::operator()(const ACL_ACCESS &a, const ACL_ACCESS &b) {
  if (a.host.ip != 0) {
    if (b.host.ip != 0) {
      /* Both elements have specified IPs. The one with the greater mask goes
       * first. */
      if (a.host.ip_mask_type != b.host.ip_mask_type)
        return a.host.ip_mask_type < b.host.ip_mask_type;

      /* if masks are not equal compare these */
      if (a.host.ip_mask != b.host.ip_mask)
        return a.host.ip_mask > b.host.ip_mask;

      /* otherwise stick with the sort value */
      return a.sort > b.sort;
    }
    /* The element with the IP goes first. */
    return true;
  }

  /* The element with the IP goes first. */
  if (b.host.ip != 0) return false;

  /* None of the elements has IP defined. Use default comparison. */
  return a.sort > b.sort;
}

grant select on my_db.* to u2@'%'

grant insert on `my\_db`.* to u2@'%'
这两个操作对应的 ACL_DB 对象在 IP 和用户名上是相同的,但库名不同。由于第二个操作中的
my\_db
没有使用通配符,因此其排序值更高,这就导致在 acl_dbs 中,第二个 GRANT 操作的 ACL_DB 对象的位置会比第一个操作靠前。

这就是为什么在执行完第二个 GRANT 后,再次执行之前的 SELECT 操作会报错。

通过 DML 操作修改了权限表,为什么要执行 FLUSH PRIVILEGES?

为了提高权限的验证效率,MySQL 会将权限表的数据缓存在内存中,具体包括:

  • mysql.user 的数据存储在 acl_users 中。
  • mysql.db 的数据存储在 acl_dbs 中。
  • mysql.tables_priv、mysql.columns_priv 的数据存储在 column_priv_hash 中。
  • mysql.procs_priv 的数据存储在 proc_priv_hash、func_priv_hash 中。
  • mysql.proxies_priv 的数据存储在 acl_proxy_users 中。

在验证权限时,MySQL 会基于内存中的数据进行验证,不会直接访问权限表。

如果通过 DML 操作修改了权限表,内存中的权限数据不会自动更新。此时,需要执行
FLUSH PRIVILEGES
,该命令会清空内存中的权限数据并重新加载权限表中的内容。

相反,当通过 GRANT 或 REVOKE 命令调整权限时,就无需执行
FLUSH PRIVILEGES
,因为这些操作会同步更新权限表和内存中的权限数据。

权限表中记录的顺序会影响权限认证的结果吗?

基本不影响。

在将权限表中的数据加载到内存对应的数据结构时,一般都会调用
ACL_USER_compare()

ACL_compare()
对数据结构进行重新排序。

以下是加载
mysql.user
表时的实现细节。

// mysql-8.4.2/sql/auth/acl_table_user.cc
bool Acl_table_user_reader::driver() {
  ...
  // 将 mysql.user 的内容加载到 acl_users 中
  while (!(read_rec_errcode = m_iterator->Read())) {
    if (read_row(is_old_db_layout, super_users_with_empty_plugin)) return true;
  }

  m_iterator.reset();
  if (read_rec_errcode > 0) return true;
  // 基于 ACL_USER_compare() 中的规则对 acl_users 进行重新排序。 
  std::sort(acl_users->begin(), acl_users->end(), ACL_USER_compare());
  acl_users->shrink_to_fit();
  // 重建 name_to_userlist。
  rebuild_cached_acl_users_for_name();
  ...
  return false;
}

需要注意的是,在 MySQL 8.0.34 之前的小版本中,如果在案例 2 中创建的账号的主机名不是
%
,而是一个具体的 IP(例如
10.0.0.0/255.255.255.0

10.0.0.0/24

10.0.0.108
),那么第二次执行 SELECT 操作时将不会报错。

为什么又不会报错呢?

我们之前提到的排序规则(
ACL_compare()
)是从 MySQL 8.0.34 版本开始引入的。在此之前,当两个对象的 IP 相同时,排序规则并不会进一步比较它们的排序值。以下是具体的实现细节:

// mysql-8.0.33/sql/auth/sql_auth_cache.cc
bool ACL_compare::operator()(const ACL_ACCESS &a, const ACL_ACCESS &b) {
  if (a.host.ip != 0) {
    if (b.host.ip != 0) {
      /* Both elements have specified IPs. The one with the greater mask goes
       * first. */
      if (a.host.ip_mask_type != b.host.ip_mask_type)
        return a.host.ip_mask_type < b.host.ip_mask_type;

      return a.host.ip_mask > b.host.ip_mask;
    }
    /* The element with the IP goes first. */
    return true;
  }

  /* The element with the IP goes first. */
  if (b.host.ip != 0) return false;

  /* None of the elements has IP defined. Use default comparison. */
  return a.sort > b.sort;
}

因此,案例 2 中第二个 GRANT 操作对应的 ACL_DB 对象在 acl_dbs 中的位置仍位于第一个 GRANT 操作之后,这也就导致了第二次执行 SELECT 操作时不会报错。

在这种规则下,权限表中记录的顺序还会影响权限验证的结果。简单来说,案例 2 中的两个 GRANT 操作,谁先执行,谁将决定该账号对于
my_db
库的权限。

上述问题在 MySQL 5.7 中不会出现,因为 MySQL 5.7 中的排序规则会比较对象的排序值。

// mysql-5.7.44/sql/auth/sql_auth_cache.cc
class ACL_compare :
  public std::binary_function<ACL_ACCESS, ACL_ACCESS, bool>
{
public:
  bool operator()(const ACL_ACCESS &a, const ACL_ACCESS &b)
  {
    return a.sort > b.sort;
  }
};

通过 GRANT/REVOKE 修改权限后,是否需要 KILL 已有连接?

首先,我们以案例 2 中的
select * from my_db.t1
语句为例,看看 MySQL 中的权限检查流程。

// mysql-8.4.2/sql/sql_select.cc
bool Sql_cmd_select::precheck(THD *thd) {
  ...
  bool res;
  if (tables)
    res = check_table_access(thd, SELECT_ACL, tables, false, UINT_MAX, false);
  else
    res = check_access(thd, SELECT_ACL, any_db, nullptr, nullptr, false, false);

  return res || check_locking_clause_access(thd, Global_tables_list(tables));
}

如果 tables 不为空(表示有具体的表要查询),则调用
check_table_access
来检查用户是否对 tables 中的所有表都拥有 SELECT 权限。

下面是具体的权限检查流程:

  1. 首先检查该用户是否有全局级别的 SELECT 权限,此时的权限信息来自于
    m_master_access

  2. 如果用户没有全局级别的 SELECT 权限,MySQL 会继续检查用户是否有对
    my_db
    库的 SELECT 权限,此时的权限信息来自于acl_dbs。

  3. 若库级别的 SELECT 权限也不存在,MySQL 会继续检查用户是否有对
    my_db.t1
    表的 SELECT 权限,此时的权限信息来自 column_priv_hash。

acl_dbs 我们之前介绍过,用来缓存
mysql.db
表的权限数据。当通过 GRANT 或 REVOKE 命令调整权限时,会同步更新
mysql.db
表和 acl_dbs 中的数据。column_priv_hash 也同样如此。所以如果修改的是库级别或表级别的权限,不需要
KILL
现有连接,新权限会自动生效。


m_master_access
不一样,它是在连接建立时设置的,即使该用户的全局权限后续发生了变化,
m_master_access
也不会自动更新。这也就意味着,如果修改的是全局权限,要想新权限对用户马上生效,需
KILL
该用户的已有连接。

sctx->set_master_access(acl_user->access, *(mpvio.restrictions));