2024年7月

大家好,我是码农先森。

面临现状

这次为什么要讨论这个话题,因为 Swoole 和 Go 在 PHP 程序员坊间一直都是茶语饭后的谈资,觉得懂 Swoole 和 Go 的就高人一等。相信有很多的 PHP 程序员,自打 Swoole 发布以来就从来没有使用过。还记得 Swoole 官方有一遍入门文章中曾建议学习 Swoole 之前,先学习 Liunx 操作系统、TCP/UDP 网络协议栈、Socket 通信等基础知识,作为 7 天入门、30 天精通 PHP 的靓仔们,面对这些基础知识不屑一顾,还会不时的反问,这些东西难道影响我 CURD 了?

在我的工作经历中遇到过这样的三类人,首先是:常年在 Window 系统下使用一键安装包 phpStudy 的 PHP 程序员,一辈子都没有翻开过 PHP 相关的源代码。即使在 Linux 服务器上也是使用万能的宝塔面板,一键便启动了 php-fpm、MySQL、Redis 等服务,如果服务出错误久久不能正常运行,便傻眼了不知所措,然后开始在各大技术群中发求助信并艾特各位大佬。每当我看到此种场景,心情都久久不能平静。其次是:时常游迹于 Thinkphp、Laravel、YII 等各大框架社区并对框架 MVC 模式有所贡献的 PHP 程序员,这些人还成为了这些框架的布道师,对框架的优劣势了如执掌,但俗不知自己可能已经陷入了框架的泥潭,在这个泥潭里久久不能自拔。最后是:偶尔会跨过互联网的那堵墙去看看外面世界的 PHP 程序员,有着对技术的好奇心且不甘于现状,开始尝试向下探索向上生长,但在这个追逐技术的路上不免会迷失方向,走到了技术的迷雾中。

虽说这三类人的情况都大不相同,但相信大家对 Swoole 和 Go 都有进阶学习的想法。接下来我会介绍一些学习上的建议,希望对大家能有所启发。

知识储备

Swoole 基于 C 语言开发的 PHP 异步通信扩展,其中涉及了多进程、协程、同步、异步、IO多路复用、Socket 等技术点。因此学习 Swoole 之前需要先把这些概念搞懂,才能进入下一步的学习。如果是科班出身的程序员对这些概念多少有点了解,非科班的就要多学习下基础的知识,把概念牢牢掌握。在这里我也把这些概念在 Swoole 中结合一下,比如学习了多进程就可以理解 Swoole 的多进程运行模式,学习了同步异步就理解了 Swoole 中的阻塞与非阻塞模式、学习了IO多路复用就理解了 Swoole 中的事件循环的机制、学习了协程就理解了 Swoole 中协程及通信通道的用法、学习了 Socket 套接字就理解了 Swoole 中所有的网络通信都基于 Socket 的原理,还有很多这里就不一一举例了。只要把操作系统、网络协议的基础知识学一遍,那么学习 Swoole 便会更加得心应手。反之没有这些基础,那么学起来会非常的吃力,这也是很多的 PHP 程序员学习 Swoole 举步维艰的主要原因。

再来说说学习 Go 语言要具备哪些基础,从理论上来说要和学习 Swoole 要具备同样的基础。但是有很多人会说了,我没有这些基础也能用 Go 开发项目呀,CURD 操作开发、API 接口开发溜得一皮。是的,实际上没有上面说的那些基础也能开发,但那只是 Go 应用场景的冰上一角。Go 语言的真正优势是高并发特性,要想真正用 Go 开发出高性能的项目,那这些基础知识是必不可少的,不然可能连 Go 的协程都玩不明白,搞的内存资源到处泄露,到头来 Bug 层出不穷得不偿失,反而最后还会觉得 Go 还没有 PHP 好用。

学习路径

回到这篇文章的主旨讨论的是学习 Swoole 还是学习 Go ?我认为要先学习 Swoole 再学习 Go,其中的原因文中已经有所阐述了。从近年 Swoole 的发展来看,其中很多的新特性也是从 Go 中所有借鉴。网上还有人调侃说 Swoole 越来越像 Go 语言,确实 Go 语言自诞生以来就出身贵族「谷歌」一直带着耀眼的光环,是很多语言学习的榜样。看 Github 的排行榜 Go 语言的使用热度也是逐年的上升,因此学习 Go 语言也是大的趋势,但在学习的路上也要打好地基脚踏实地,只要把自己这把刀磨好,就不怕耽误砍柴的功夫。

最后我也推荐一个基础知识的学习网站
https://xiaolincoding.com/
这里的内容大多数都是图解的方式,理解起来会相对容易些,希望对大家能有所帮助。


欢迎关注、分享、点赞、收藏、在看,我是微信公众号「码农先森」作者。

本文分享自华为云社区
《【华为云MySQL技术专栏】MySQL 派生表合并优化的原理和实现》
,作者:GaussDB 数据库。

引言

MySQL是一种流行的开源关系型数据库管理系统,广泛应用于各种Web应用程序和企业系统中。随着数据量的增加和查询复杂度的提高,优化SQL查询性能变得至关重要。派生表(Derived Table)是SQL查询中常用的一种技术,通过在主查询中嵌套子查询来实现更复杂的数据处理。然而,派生表的使用有时会导致系统的性能瓶颈。

为了解决这一问题,MySQL引入了派生表合并优化(Derived Table Merging Optimization)。本文将详细介绍派生表合并优化的原理及在MySQL中的实现。

何为派生表?

派生表是一个临时表,它是由子查询的结果集生成并在主查询中使用。简单来讲,就是将FROM子句中出现的检索结果集当成一张表,比如 FROM一个SELECT构造的子查询,这个子查询就是一个派生表;SELECT一个视图,这个视图就是一个派生表;SELECT一个WITH构造的临时表(Common table expression,CTE),这个CTE表就是一个派生表。如下图举例所示:

11.PNG

图1 子查询语句样例

MySQL优化器处理派生表有两种策略:

第一种,将派生表物化为一个临时表;
第二种,将派生表合并到外查询块中。
派生表物化为一个临时表,可能会引发性能问题,如下情况:
  • 大数据量子查询:派生表的结果集可能非常大,导致内存消耗和磁盘I/O增加。
  • 复杂查询:多层嵌套查询或包含多个派生表的查询,会使优化器难以选择最佳执行计划。
  • 不可索引:派生表的结果集是临时的,无法直接使用索引进行优化。
为了解决这些问题,MySQL 引入了派生表合并优化。

派生表合并优化原理

派生表合并优化的核心思想是将派生表的子查询合并到主查询中,从而避免生成临时表。具体来说就是,优化器会尝试将派生表的子查询展开,并直接嵌入到主查询的执行计划中。这样可以减少临时表的使用,降低内存和磁盘I/O的负担,从而提高查询性能。

下文将通过案例对派生表合并优化进行详细说明。

1.案例分析

创建如下两张表:

CREATE TABLE `departments` (
`id`
intNOT NULL,
`name` varchar(
50) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE
=InnoDB;
CREATE TABLE `employees` (
`id`
intNOT NULL,
`name` varchar(
50) DEFAULT NULL,
`department_id`
intDEFAULT NULL,
`salary`
decimal(10,2) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE
=InnoDB;

对于下述的查询语句:

SELECT t1.department_id, t2.name, t1.total_salary
FROM
(SELECT department_id, SUM(salary) total_salary
FROM employees GROUP BY department_id) t1
JOIN
(SELECT id, name
FROM departments
WHERE name
='Human Resources') t2
ON t1.department_id
= t2.id;

关闭optimizer_switch(优化器开关)的derived_merge选项,对应的执行计划如下:

+----+-------------+-------------+------------+------+---------------+-------------+---------+------------------+------+----------+-----------------+
| id | select_type | table       | partitions | type | possible_keys | key         | key_len | ref              | rows | filtered |Extra|+----+-------------+-------------+------------+------+---------------+-------------+---------+------------------+------+----------+-----------------+
|  1 | PRIMARY     | <derived2>  | NULL       | ALL  | NULL          | NULL        | NULL    | NULL             |    2 |   100.00 | Using where     |
|  1 | PRIMARY     | <derived3>  | NULL       | ref  | <auto_key0>   | <auto_key0> | 4       | t1.department_id |    2 |   100.00 | NULL            |
|  3 | DERIVED     | departments | NULL       | ALL  | NULL          | NULL        | NULL    | NULL             |    1 |   100.00 | Using where     |
|  2 | DERIVED     | employees   | NULL       | ALL  | NULL          | NULL        | NULL    | NULL             |    1 |   100.00 | Using temporary |
+----+-------------+-------------+------------+------+---------------+-------------+---------+------------------+------+----------+-----------------+
4 rows in set, 1 warning (0.01 sec)

select_type列出现两行DERIVED类型, 说明派生表没有合并,派生表会物化为临时表。

执行EXPLAIN ANALYZE进一步分析,可知两个子查询都是物化为临时表后,再执行JOIN。

EXPLAIN: -> Nested loop inner join  (actual time=0.304..0.308 rows=1 loops=1)-> Table scan on t2  (cost=2.73 rows=2) (actual time=0.003..0.003 rows=1 loops=1)-> Materialize  (cost=0.55 rows=1) (actual time=0.163..0.164 rows=1 loops=1)-> Filter: (departments.`name`='Human Resources') (cost=0.55 rows=1) (actual time=0.103..0.125 rows=1 loops=1)-> Table scan on departments (cost=0.55 rows=3) (actual time=0.095..0.114 rows=3 loops=1)-> Index lookup on t1 using <auto_key0> (department_id=t2.id) (actual time=0.004..0.006 rows=1 loops=1)-> Materialize (actual time=0.137..0.139 rows=1 loops=1)-> Table scan on <temporary> (actual time=0.001..0.003 rows=3 loops=1)-> Aggregate using temporary table (actual time=0.102..0.104 rows=3 loops=1)-> Table scan on employees (cost=0.65 rows=4) (actual time=0.040..0.056 rows=4 loops=1)1 row in set (0.00 sec)

开启optimizer_switch(优化器开关)的derived_merge选项,对应的执行计划如下:

+----+-------------+-------------+------------+------+---------------+-------------+---------+---------------------+------+----------+-----------------+
| id | select_type | table       | partitions | type | possible_keys | key         | key_len | ref                 | rows | filtered | Extra           |
+----+-------------+-------------+------------+------+---------------+-------------+---------+---------------------+------+----------+-----------------+
|  1 | PRIMARY     | departments | NULL       | ALL  | PRIMARY       | NULL        | NULL    | NULL                |    1 |   100.00 | Using where     |
|  1 | PRIMARY     | <derived2>  | NULL       | ref  | <auto_key0>   | <auto_key0> | 5       | test.departments.id |    2 |   100.00 | NULL            |
|  2 | DERIVED     | employees   | NULL       | ALL  | NULL          | NULL        | NULL    | NULL                |    1 |   100.00 | Using temporary |
+----+-------------+-------------+------------+------+---------------+-------------+---------+---------------------+------+----------+-----------------+
3 rows in set, 1 warning (0.00 sec)

从执行计划可以看出,select_type列上只有一行为DERIVED类型,说明发生了派生表合并。

执行EXPLAIN ANALYZE进一步分析,employees表上的子查询仍然会被物化为临时表。departments表上的子查询(派生表)进行了合并优化,departments表直接与临时表t1进行JOIN。

EXPLAIN: -> Nested loop inner join (actual time=0.271..0.295 rows=1 loops=1)-> Filter: (departments.`name` = 'Human Resources') (cost=0.55 rows=1) (actual time=0.103..0.122 rows=1 loops=1)-> Table scan on departments (cost=0.55 rows=3) (actual time=0.095..0.112 rows=3 loops=1)-> Index lookup on t1 using <auto_key0> (department_id=departments.id) (actual time=0.005..0.007 rows=1 loops=1)-> Materialize (actual time=0.164..0.166 rows=1 loops=1)-> Table scan on <temporary> (actual time=0.002..0.004 rows=3 loops=1)-> Aggregate using temporary table (actual time=0.114..0.117 rows=3 loops=1)-> Table scan on employees (cost=0.65 rows=4) (actual time=0.044..0.065 rows=4 loops=1)1 row in set (0.00 sec)

对比derived_merge选项开启和关闭的两个执行计划可知,开启派生表合并优化特性后,departments表上的子查询(派生表)不再物化为临时表,而是合并到了父查询,进而简化了执行计划,并提高了执行效率。

另外,也可以发现,并不是所有派生表都可以合并优化,比如,案例中的employees表上的子查询(派生表),因为含有聚合函数,就无法进行合并优化。

2.应用场景限制

如下场景中派生表合并优化是无效的:

1)派生表中含有聚合函数,或者含有DISTINCT、GROUP BY、HAVING这些分组子句。比如,案例中的派生表t1包含了聚合函数和GROUP BY分组就无法合并优化。

2)派生表的SELECT列表中有子查询,也就是标量子查询。比如:

select *
from (selectstuno,
course_no,
(
selectcourse_namefromcourse cwhere c.course_no = a.course_no) ascourse_name,
score
fromscore a) bwhere b.stuno = 1;

因为派生表b的select 列表中有标量子查询,无法合并,会被物化。

3)分配了用户变量。比如:

select (@i := @i + 1) asrownum, stuno, course_no, course_name, scorefrom ((selecta.stuno, a.course_no, b.course_name, a.scorefromscore a
left join course b
on a.course_no
= b.course_no) dt, (select (@i := 0) num) c)where stuno = 1;

上面这个例子使用用户变量的形式给记录加了行号,不能合并。

4)如果合并会导致外查询块中超过61张基表的连接访问,优化器会选择物化派生表。

5)UNION或UNION ALL。比如:

select id, c1from (selectid, c1fromt1
union
select id, c1 fromt2) dtwhere dt.id = 1;

因为派生表dt有union操作,无法合并,会被物化。

6)对于视图而言,创建视图时如果指定了ALGORITHM=TEMPTABLE,它会阻止合并,这个属性的优先级比优化器开关的优先级要高。

7)派生表中含LIMIT子句,因为合并会导致结果集改变。比如:

select * from (select id,c1 from t1 limit 10) a where a. id=1;

8)只引用了字面量值。比如:

select * from (select '1' as c1, 2 as c2 ) a;

源码分析

1.背景知识

我们使用的MySQL代码版本号为8.0.22。在介绍派生表代码实现之前,先了解下MySQL描述一条查询的逻辑语法树结构,有4个较为核心的类:

SELECT_LEX_UINT

对于一个query expression的描述结构,其中可以包含union/union all等多个query block的集合运算,同时SELECT_LEX_UNIT也根据query的结构形成递归包含关系。

SELECT_LEX

对于一个query block的描述结构,就是我们最为熟悉SPJ(选择Selection、投影Projection、连接Join) + group by + order by + select list... 这样的一个查询块,一个SELECT_LEX_UNIT中可能包含多个SELECT_LEX,而SELECT_LEX内部则也可能嵌套包含其他SELECT_LEX_UNIT。

Item

对于expression的描述结构,例如on条件、where条件、投影列等,都是用这个类来描述一个个表达式的,Item系统是MySQL SQL层代码中最为复杂的子系统之一,其构成了表达式树。

TABLE_LIST

对于表信息的描述结构。TABLE_LIST不仅仅针对SQL表达式中的物理表,也可以表示其他类型的表,例如视图、临时表、派生表等。此外,TABLE_LIST类还用于处理别名和连接等信息。

TABLE_LIST类是MySQL查询处理的核心部分,涵盖了SQL表达式中的各种表类型。以案例中的SQL查询语句为例,在派生表合并优化前,其对应的类实例映射关系如下:

22.PNG

图2 派生表合并优化前的SQL语句

33.PNG

图3 派生表合并优化前的逻辑语法树

图2为SQL表达式,图3为MySQL处理后对应的逻辑语法树。图2颜色涵盖的SQL语句范围与图3相同颜色的类实例一一对应。比如,图2米黄色涵盖了整条SELECT语句(query block),也就对应着图3的SELECT_LEX1实例;图2最外层的浅灰色包含了米黄色区域,代表整条SQL语句(query expression),对应着图3的SELECT_LEX_UINT1实例(不涉及UNION操作,SELECT_LEX_UINT1只包含SELECT_LEX1,即一个SELECT_LEX实例)。

图2中用括号圈起来的部分,就是一个SELECT_LEX_UNIT,而每个SELECT toke开始的一个query block,就是一个SELECT_LEX,而在外层的SELECT_LEX中,会嵌套子查询,用一个SELECT_LEX_UNIT描述,子查询中可以是任意查询形态,再包含多个SELECT_LEX,从而形成SELECT_LEX_UNIT -> SELECT_LEX -> SELECT_LEX_UNIT -> SELECT_LEX ... 这种相互嵌套的结构。

最外层的 query block(SELECT_LEX1)有两个派生表(t1、t2)。t1 和 t2 通过 derived 指针分别指子查询 query expression(SELECT_LEX_UINT3、SELECT_LEX_UINT2)。

2. 代码实现

MySQL主要在prepare阶段处理派生表的合并优化,详细的函数调用和处理过程如下:

->Sql_cmd_dml::prepare->Sql_cmd_select::prepare_inner->SELECT_LEX::prepare
顶层 query block 的处理,全局入口
->SELECT_LEX::resolve_placeholder_tables
处理query block中的第一个 derived table
->TABLE_LIST::resolve_derived->创建Query_result_union对象,在执行derived子查询时,用来向临时表里写入结果数据->调用内层嵌套SELECT_LEX_UNIT::prepare,对derived table对应的子查询做递归处理->SELECT_LEX::prepare->判断derived中的子查询是否允许merge到外层,当满足如下任一条件时,“有可能”可以merge到外层:1. derived table属于最外层查询2. 属于最外层的子查询之中,且query是一个SELECT查询->SELECT_LEX::resolve_placeholder_tables 嵌套处理derived table这个子查询内部的derived table...
... 处理query block中其他的各个组件,包括condition
/group by/rollup/distinct/order by...->SELECT_LEX::transform_scalar_subqueries_to_join_with_derived
... 一系列对query block(Item中的)处理,略过
->SELECT_LEX::apply_local_transforms 做最后的一些查询变换(针对最外层query block)1. 简化join,把嵌套的join表序列尽可能展开,去掉无用的join,outer join转inner join等2. 对分区表做静态剪枝->SELECT_LEX::push_conditions_to_derived_tables(针对最外层query block)
外 query block 中与 derived table 相关的条件会推入到派生表中
->至此derived table对应的子查询部分resolve完成->TABLE_LIST::is_mergeable->SELECT_LEX_UNIT::is_mergeable
判断当前 derived table 是否可以merge到外层,要同时满足如下的要求:(只支持最简单的SPJ查询)
1. derived table query expression 没有union2. derived table query block 没有聚合/窗口函数+group by + 没有having + 没有distinct + 有table + 没有window +没有limit->SELECT_LEX::merge_derived,确定可以展开到外层后,执行 merge_derived 动作->再做一系列的检查看是否可以merge1. 外层query block是否允许merge,例如CREATE VIEW/SHOW CREATE这样的命令,不允许做merge2. 基于启发式,检查derived子查询的投影列是否有子查询,有则不做merge3. 如果外层有straight_join,而derived子查询中有semi-join/anti-join,则不允许merge4. 外层表的数量达到MySQL能处理的最大值->通过检查后,开始merge1. 把内层join列表合并到外层中2. 把where条件与外层的where条件做AND组合3. 把投影列合并到外层投影列中->对于不能展开的,采用物化方式执行,setup_materialized_derived
处理query block中的其它 derived table,...
->resolve_placeholder_tables 处理完成
顶层 query block 的其它处理 ...

案例中的SQL语句经过上面的派生表的合并优化处理后,其对应的映射关系如下:

44.PNG

图4 派生表合并优化后的SQL语句

55.PNG

图5 派生表合并优化后的逻辑语法树

对比合并优化前,有如下变化:(图4的SQL语句已基于图5的逻辑语法树等价变换)

1)派生表t2所指向的内部 query expression(SELECT_LEX_UINT2/SELECT_LEX2)已消除。

2)SELECT_LEX2中的物理表departments上移至外部query block(SELECT_LEX1)的JOIN运算中。

3)SELECT_LEX2中的WHERE条件合并到SELECT_LEX1。

4)SELECT_LEX1中针对派生表t2的投影,替换为物理表departments。

原理证明

前文描述了MySQL派生表合并优化的具体实现,那么,如何从原理上证明该优化方法的正确性呢?可以尝试根据关系代数定理对其进行论证。

先简化场景,假设有两个表,一个是主查询(主表)R,一个是派生表D。在没有合并优化之前,查询可能是这样的形式:

1)外层查询从派生表中选择数据:σ条件1(D)
2)派生表D是从另一个或多个表导出的结果,通过一定的操作如选择σ条件2、投影π属性或连接⋈等得到。

不考虑具体实现的复杂性,让我们通过一个简单查询的例子来说明外层查询和派生表合并的效果。假设派生表D是从主表R通过选择操作产生的:D = σ条件2(R),而外层查询又对D进行选择:σ条件1(D)。

根据关系代数的选择的叠加律(σa(σb(R)) = σa ∧ b(R)),可以合并这两个选择操作为一个操作,直接作用在主表R上:σ条件1 ∧ 条件2(R)。

这样,外层查询和派生表D就被合并成了一个直接对原始表R进行操作的查询,省去了创建和访问派生表D的开销。

对于更复杂的派生表,它们可能通过多个操作,如连接、投影和选择,从一个或多个表导出。针对这样的情况,基于关系代数的性质,比如选择的叠加律和交换律、投影的结合律等,通过相应的关系代数变换,所有这些操作的组合都可以被重写为直接作用于原始表上的一系列操作,也就证明了MySQL的这一优化方式是有效的。

总结

本文从一个案例出发梳理了MySQL派生表合并优化的流程实现和优化原理,并对优化前后同一条SQL语句在代码层面的类实例映射关系进行了对比。MySQL派生表合并的代码实现细节比较多,篇幅有限,不再赘述,希望本文能够作为一个参考,帮助感兴趣的读者进一步研究这部分源码。

点击关注,第一时间了解华为云新鲜技术~

拿获取作品列表为例

https://cp.kuaishou.com/rest/cp/works/v2/video/pc/photo/list?__NS_sig3=xxxxxxxxxxx

搜索__NS_sig3

发现__NS_sig3是一个异步回调生成的值

s().call("$encode", [i, {suc: function(e) {t(`__NS_sig3=${e}`)},err: function(t) {e(t)}}])

具体逻辑就是:

  • s().call("$encode", [...])
    : 这个部分表示调用对象
    s()
    上的
    call
    方法,并传递
    $encode
    作为第一个参数。这里的
    s()
    可能是某个对象或函数的调用,它返回了一个对象,该对象具有
    call
    方法。不明白
    call
    方法的可以参考这篇文章https://juejin.cn/post/7158686309211439141
  • "$encode"
    : 这是传递给
    call
    方法的第一个参数
  • [i, { ... }]
    : 这是传递给
    call
    方法的第二个参数,是一个数组。数组的第一个元素
    i
    就是要加密的原数据。第二个元素是一个包含
    suc

    err
    回调函数的对象。

打断点到
s()...
这里,看看
i
是什么

看起来
i
也是一个加密的值,有点像md5加密之后的样子,往上找
i
生成的地方

const i = m(t, e, n);

其中
t
是json字符串,
e
是cookies中的kuaishou.web.cp.api_ph的值,
n
是一个空对象

进到
m
函数里面发现
e
被JSON序列化之后有被加到了
d(i)
后面,最终调用
a()(s)
被加密出来。看到这里就可以愉快的扣js了,哪里没有补哪里。就不过多赘述了。

搞定
i
之后就可继续往下走了,既然__NS_sig3是通过
s().call()
生成的,自然我们就要搞清楚
s()
是在哪里定义的

找到s的定义
s = n.n(o)
再找到o的定义
o = n(7606)
,此时我们发现7606这个东西好眼熟,没错就是我们的老朋友webpack,接下来就开始扣webpack,如何扣webpack的步骤这里就不赘述了,网上的教程一抓一大把。

最后验证一下结果

Nice ,接下来放到python里面跑一下看看是否能成功

  • 先来一个错误的sig看看什么样子

  • 再试试我们自己生成的sig的结果

完美运行~

该部分算法和app中的某部分雷同

**本文仅供学习参考,如有问题询问或有需要可留言,或加q 1018866051 **请备注算法交流

详解数据分布不匹配时,偏差与方差的分析

估计学习算法的偏差和方差真的可以帮确定接下来应该优先做的方向,但是,当训练集来自和开发集、测试集不同分布时,分析偏差和方差的方式可能不一样,来看为什么。

继续用猫分类器为例,说人类在这个任务上能做到几乎完美,所以贝叶斯错误率或者说贝叶斯最优错误率,知道这个问题里几乎是0%。所以要进行错误率分析,通常需要看训练误差,也要看看开发集的误差。比如说,在这个样本中,训练集误差是1%,开发集误差是10%,如果开发集来自和训练集一样的分布,可能会说,这里存在很大的方差问题,算法不能很好的从训练集出发泛化,它处理训练集很好,但处理开发集就突然间效果很差了。

但如果训练数据和开发数据来自不同的分布,就不能再放心下这个结论了。特别是,也许算法在开发集上做得不错,可能因为训练集很容易识别,因为训练集都是高分辨率图片,很清晰的图像,但开发集要难以识别得多。所以也许软件没有方差问题,这只不过反映了开发集包含更难准确分类的图片。所以这个分析的问题在于,当看训练误差,再看开发误差,有两件事变了。首先算法只见过训练集数据,没见过开发集数据。第二,开发集数据来自不同的分布。而且因为同时改变了两件事情,很难确认这增加的9%误差率有多少是因为算法没看到开发集中的数据导致的,这是问题方差的部分,有多少是因为开发集数据就是不一样。

为了弄清楚哪个因素影响更大,如果完全不懂这两种影响到底是什么。但为了分辨清楚两个因素的影响,定义一组新的数据是有意义的,称之为训练-开发集,所以这是一个新的数据子集。应该从训练集的分布里挖出来,但不会用来训练网络。

意思是已经设立过这样的训练集、开发集和测试集了,并且开发集和测试集来自相同的分布,但训练集来自不同的分布。

要做的是随机打散训练集,然后分出一部分训练集作为训练-开发集(training-dev),就像开发集和测试集来自同一分布,训练集、训练-开发集也来自同一分布。

但不同的地方是,现在只在训练集训练神经网络,不会让神经网络在训练-开发集上跑后向传播。为了进行误差分析,应该做的是看看分类器在训练集上的误差,训练-开发集上的误差,还有开发集上的误差。

比如说这个样本中,训练误差是1%,说训练-开发集上的误差是9%,然后开发集误差是10%,和以前一样。就可以从这里得到结论,当从训练数据变到训练-开发集数据时,错误率真的上升了很多。而训练数据和训练-开发数据的差异在于,神经网络能看到第一部分数据并直接在上面做了训练,但没有在训练-开发集上直接训练,这就告诉,算法存在方差问题,因为训练-开发集的错误率是在和训练集来自同一分布的数据中测得的。所以知道,尽管神经网络在训练集中表现良好,但无法泛化到来自相同分布的训练-开发集里,它无法很好地泛化推广到来自同一分布,但以前没见过的数据中,所以在这个样本中确实有一个方差问题。

来看一个不同的样本,假设训练误差为1%,训练-开发误差为1.5%,但当开始处理开发集时,错误率上升到10%。现在方差问题就很小了,因为当从见过的训练数据转到训练-开发集数据,神经网络还没有看到的数据,错误率只上升了一点点。但当转到开发集时,错误率就大大上升了,所以这是数据不匹配的问题。因为学习算法没有直接在训练-开发集或者开发集训练过,但是这两个数据集来自不同的分布。但不管算法在学习什么,它在训练-开发集上做的很好,但开发集上做的不好,所以总之算法擅长处理和关心的数据不同的分布,称之为数据不匹配的问题。

再来看几个样本,因上面没空间了。所以训练误差、训练-开发误差、还有开发误差,说训练误差是10%,训练-开发误差是11%,开发误差为12%,要记住,人类水平对贝叶斯错误率的估计大概是0%,如果得到了这种等级的表现,那就真的存在偏差问题了。存在可避免偏差问题,因为算法做的比人类水平差很多,所以这里的偏差真的很高。

最后一个例子,如果训练集错误率是10%,训练-开发错误率是11%,开发错误率是20%,那么这其实有两个问题。第一,可避免偏差相当高,因为在训练集上都没有做得很好,而人类能做到接近0%错误率,但算法在训练集上错误率为10%。这里方差似乎很小,但数据不匹配问题很大。所以对于这个样本,如果有很大的偏差或者可避免偏差问题,还有数据不匹配问题。

写出一般的原则,要看的关键数据是人类水平错误率,训练集错误率,训练-开发集错误率,所以这分布和训练集一样,但没有直接在上面训练。根据这些错误率之间差距有多大,可以大概知道,可避免偏差、方差数据不匹配问题各自有多大。

说人类水平错误率是4%的话,训练错误率是7%,而训练-开发错误率是10%,而开发错误率是12%,这样就大概知道可避免偏差有多大。因为知道,希望算法至少要在训练集上的表现接近人类。而这大概表明了方差大小,所以从训练集泛化推广到训练-开发集时效果如何?而这告诉数据不匹配的问题大概有多大。技术上还可以再加入一个数字,就是测试集表现,写成测试集错误率,不应该在测试集上开发,因为不希望对测试集过拟合。但如果看看这个,那么这里的差距就说明对开发集过拟合的程度。所以如果开发集表现和测试集表现有很大差距,那么可能对开发集过拟合了,所以也许需要一个更大的开发集,对吧?要记住,开发集和测试集来自同一分布,所以这里存在很大差距的话。如果算法在开发集上做的很好,比测试集好得多,那么就可能对开发集过拟合了。如果是这种情况,那么可能要往回退一步,然后收集更多开发集数据。现在写出这些数字,这数字列表越往后数字越大。

这里还有个例子,其中数字并没有一直变大,也许人类的表现是4%,训练错误率是7%,训练-开发错误率是10%。但看看开发集,发现,很意外,算法在开发集上做的更好,也许是6%。所以如果见到这种现象,比如说在处理语音识别任务时发现这样,其中训练数据其实比开发集和测试集难识别得多。所以这两个(7%,10%)是从训练集分布评估的,而这两个(6%,6%)是从开发测试集分布评估的。所以有时候如果开发测试集分布比应用实际处理的数据要容易得多,那么这些错误率可能真的会下降。所以如果看到这样的有趣的事情,可能需要比这个分析更普适的分析。

所以,就以语音激活后视镜为例子,事实证明,一直写出的数字可以放到一张表里,在水平轴上,要放入不同的数据集。比如说,可能从一般语音识别任务里得到很多数据,所以可能会有一堆数据,来自小型智能音箱的语音识别问题的数据,购买的数据等等。然后收集了和后视镜有关的语音数据,在车里录的。所以这是表格的
\(x\)
轴,不同的数据集。在另一条轴上,要标记处理数据不同的方式或算法。

首先,人类水平,人类处理这些数据集时准确度是多少。然后这是神经网络训练过的数据集上达到的错误率,然后还有神经网络没有训练过的数据集上达到的错误率。所以结果是人类水平的错误率,数字填入这个单元格里(第二行第二列),人类对这一类数据处理得有多好,比如来自各种语音识别系统的数据,那些进入训练集的成千上万的语音片段,而例子是4%。这个数字(7%),可能是训练错误率,在例子中是7%。是的,如果学习算法见过这个样本,在这个样本上跑过梯度下降,这个样本来自训练集分布或一般的语音识别数据分布,算法在训练过的数据中表现如何呢?然后这就是训练-开发集错误率,通常来自这个分布的错误率会高一点,一般的语音识别数据,如果算法没在来自这个分布的样本上训练过,它的表现如何呢?这就是说的训练-开发集错误率。

如果移到右边去,这个单元格是开发集错误率,也可能是测试集错误,在刚刚的例子中是6%。而开发集和测试集,实际上是两个数字,但都可以放入这个单元格里。如果有来自后视镜的数据,来自从后视镜应用在车里实际录得的数据,但神经网络没有在这些数据上做过反向传播,那么错误率是多少呢?

分析是观察这两个数字之间的差异(
Human level 4%

Training error 7%
),还有这两个数字之间(
Training error 7%

Training-dev error 10%
),这两个数字之间(
Training-dev error 10%

Dev/Test dev 6%
)。这个差距(
Human level 4%

Training error 7%
)衡量了可避免偏差大小,这个差距
Training error 7%

Training-dev error 10%
)衡量了方差大小,而这个差距(
Training-dev error 10%

Dev/Test dev 6%
)衡量了数据不匹配问题的大小。

事实证明,把剩下的两个数字(
rearview mirror speech data 6%

Error on examples trained on 6%
),也放到这个表格里也是有用的。如果结果这也是6%,那么获得这个数字的方式是让一些人自己标记他们的后视镜语音识别数据,看看人类在这个任务里能做多好,也许结果也是6%。做法就是,收集一些后视镜语音识别数据,把它放在训练集中,让神经网络去学习,然后测量那个数据子集上的错误率,但如果得到这样的结果,好吧,那就是说已经在后视镜语音数据上达到人类水平了,所以也许对那个数据分布做的已经不错了。

当继续进行更多分析时,分析并不一定会给指明一条前进道路,但有时候可能洞察到一些特征。比如比较这两个数字(
General speech recognition Human level 4%

rearview mirror speech data 6%
),告诉对于人类来说,后视镜的语音数据实际上比一般语音识别更难,因为人类都有6%的错误,而不是4%的错误,但看看这个差值,就可以了解到偏差和方差,还有数据不匹配这些问题的不同程度。所以更一般的分析方法是,已经用过几次了。还没用过,但对于很多问题来说检查这个子集的条目,看看这些差值,已经足够让往相对有希望的方向前进了。但有时候填满整个表格,可能会洞察到更多特征。

最后,以前有过很多处理偏差的手段,讲过处理方差的手段,但怎么处理数据不匹配呢?特别是开发集、测试集和训练集数据来自不同分布时,这样可以用更多训练数据,真正帮提高学习算法性能。但是,如果问题不仅来自偏差和方差,现在又有了这个潜在的新问题,数据不匹配,有什么好办法可以处理数据不匹配的呢?实话说,并没有很通用,或者至少说是系统解决数据不匹配问题的方法,但可以做一些尝试,可能会有帮助。

最近使用
nuxt@3.x
版本做SEO优化项目比较多,之前也踩坑过,所以记录一下在
nuxt3
中路由缓存的正确使用方法,本人也之前在GitHub社区中提交过
反馈问题
,最后是在
3.8.2
版本解决了路由缓存问题。下面讲解如何正确使用keepalive做到页面缓存,组件缓存。

# 环境版本如下
node # 21.4.0
nuxt # 3.12.3
vue # latest  目前最新版本 3.4.*

页面缓存

keepalive 我们知道都是用来缓存组件 在组件卸载的时候 并不去真正意义上的销毁,而是隐藏掉。等再次挂载的时候再把它显示出来。其组件的状态保持原有的状态,并不会初始化。写多了
vue
项目的小伙伴们,大部分通过路由文件(
src/router.js
)定义
name
值,再去
router-view
组件里包裹
keepalive
组件去判断
路由
里面的
name
值去使用。但是在
nuxt
框架内不需要这么复杂的操作。

  • app.vue文件内容
<!-- app.vue -->
<template>
    <div>
        <!-- 最新版vue支持的语法,老版本可能提示错误 -->
        <NuxtPage :keepalive />
    </div>
</template>
<script lang="ts" setup>
    // keepalive 所需的参数 指定name值为index 的页面 进行缓存
    const keepalive = {
        include: ["index"],
    };
</script>
  • pages文件夹
pages
├─index.vue # 若组件未定义name值 则为 文件名 index
└─user.vue # name为user
  • index.vue文件内容
<template>
    <div>index</div>
    <p>data: {{ data }}</p>
    <p>
        <button @click="data++">+1</button>
        <button @click="data--">-1</button>
    </p>
    <p class="links">
        <NuxtLink to="/user">user Page</NuxtLink>
    </p>
</template>
<script setup lang="ts">
    const data = ref(0);
</script>

项目启动路径为
http://localhost:3000/
的时候,此时,在组件内操纵
data
的值变化,再去跳转
http://localhost:3000/user
时,再跳回
http://localhost:3000/

data
的值不会初始化,而是切换
/user
路由前页面的状态。此时使用
onActivated
api来监听组件被激活。

<script lang="ts" setup>
    onActivated(() => {
        console.log("onActivated 页面激活了!");
    });
</script>
  • 路径复杂的请使用
    defineOptions
    指定组件name值,以免使用路由缓存失败!

例如:
pages/userData.vue

pages/news/detail

pages/news/[id].vue
。这几个路径过于复杂,在
NuxtPage
组件中难以使用
include
属性值去判断缓存条件,所以需要在页面文件中声明该页面的组件
name
值。

<!-- pages/index.vue -->
<script setup lang="ts">
defineOptions({
  name: "IndexPage", 
}); 
</script>

<!-- pages/news/detail.vue -->
<script setup lang="ts">
defineOptions({
  name: "newsDetail", 
}); 
</script>

<!-- app.vue -->
<template>

    <div>
        <NuxtPage :keepalive />
    </div>

</template>
<script lang="ts" setup>

    const keepalive = {
        include: ["IndexPage","newsDetail"], 
        // 对应 pages/index.vue ,pages/news/detail.vue 中的 name 值 缓存路由
    };

</script>

组件缓存

如果在页面中,使用了
v-if
指令来控制组件显示,如何保证组件数据不被销毁。当然还是使用
keepalive
组件。假设我们在
components
文件中定义组件,会自动全局导入,无需引用。

components
├─Val
│  └─Input.vue # 若组件未定义name值 则为 文件名 Input
├─Counter.vue # 若组件未定义name值 则为 文件名 Counter
└─TextTip.vue # name 为 TextTip
<!-- components/Counter.vue -->
<template>
    <div class="counter">
        <h2>counter</h2>
        <p>data:{{ data }}</p>
        <p>
            <button @click="data++">+1</button>
            <button @click="data--">-1</button>
        </p>
    </div>
</template>

<script lang="ts" setup>
    const data = ref(0);
</script>
<style scoped>
    .counter {
        padding: 20px;
    }
</style>

<!-- components/TextTip.vue -->
<template>
    <div class="text-tip">
        <h2>text-tip</h2>
        <p>text: {{ text }}</p>
        <p>
            <button @click="text += '1'">add Text</button>
        </p>
    </div>
</template>
<script lang="ts" setup>
    const text = ref("text-1");
</script>

<!-- pages/index.vue -->
<template>
    <div>index</div>
    <p>
        <button @click="showCounter = !showCounter">
            {{ !showCounter ? "显示Counter" : "显示TextTip" }}
        </button>
    </p>
    <hr />
    <KeepAlive :include="keep.include">
        <Counter v-if="showCounter" />
        <TextTip v-else />
    </KeepAlive>
</template>
<script setup lang="ts">
    const showCounter = ref(false);
    const keep = {
        include: ["Counter"],
    };
</script>

此时点击
显示Counter
按钮,在
Counter
组件中操作内部数据改变
data:5
,点击按钮再去切换组件显示隐藏,会发现
Counter
组件并不会销毁掉之前的值
data:5
,而TextTip组件在操作内部的数据改变后切换
隐藏/显示
后,
text
数据是初始化的值
text-1

  • 注意components文件定义的组件name值

components/Counter.vue

components/Val/Input.vue

name
值nuxt自动会给组件的name值取为文件名
Counter

Input
。而在组件自动导入的时候却是使用
<Counter />

<ValInput />
。会有点迷惑,所以请在使用
<KeepAlive>
组件包裹来缓存状态,请务必使用
defineOptions
指定组件的
name
值。

<!-- components/Val/Input.vue -->
<template>
    <div class="input-warpper">
        <h2>Input</h2>
        <input v-model="val" />
    </div>
</template>
<script lang="ts" setup>
    const val = ref("");
    defineOptions({
        name: "ValInput"
    })
</script>
<style>
    .input-warpper {
        margin: 20px;
    }
</style>

或者使用
export default { name:'xxx' }
来指定组件的name值,不使用
setup
语法。

<!-- components/Val/Input.vue -->
<template>
    <div class="input-warpper">
        <h2>Input</h2>
        <input v-model="val" />
    </div>
</template>
<script lang="ts" setup>
    const val = ref("");
</script>
<script lang="ts">
    export default {
        name: "ValInput"
    }
</script>
<style>
    .input-warpper {
        margin: 20px;
    }
</style>

案例地址

点击这里
跳转代码案例,来调试 keepalive 页面缓存 和 组件缓存

推荐环境版本:
node v21.4.0
,
nuxt v3.12.*
, 使用
pnpm 安装依赖