之前写过一篇介绍
Python

dataclass
的文章:
《掌握python的dataclass,让你的代码更简洁优雅》

那篇侧重于介绍
dataclass
的使用,今天想探索一下这个有趣的特性是如何实现的。

表面上看,
dataclass
就是一个普通的装饰器,但是它又在
class
上实现了很多神奇的功能,

为我们在
Python
中定义和使用
class
带来了极大的便利。

如果你也好奇它在幕后是如何工作的,本篇我们就一同揭开
Python

dataclass
的神秘面纱,

深入探究一下其内部原理。

1. dataclass简介

dataclass
为我们提供了一种简洁而高效的方式来定义类,特别是那些主要用于存储数据的类。

它能自动为我们生成一些常用的方法,如
__init__

__repr__
等,大大减少了样板代码的编写。

例如,我在量化中经常用的一个
K线
数据,用dataclass来定义的话,如下所示:

from dataclasses import dataclass
from datetime import datetime

@dataclass
class KLine:
    name: str = "BTC"
    open_price: float = 0.0
    close_price: float = 0.0
    high_price: float = 0.0
    low_price: float = 0.0
    begin_time: datetime = datetime.now()

if __name__ == "__main__":
    kl = KLine()
    print(kl)

这样,我们无需手动编写
__init__
方法来初始化对象,就可以轻松创建
KLine
类的实例,

并且直接打印对象也可以得到清晰,易于阅读的输出。

$  python.exe .\kline.py
KLine(name='BTC', open_price=0.0, close_price=0.0, 
high_price=0.0, low_price=0.0, 
begin_time=datetime.datetime(2025, 1, 2, 17, 45, 53, 44463))

但这背后究竟发生了什么呢?

2. 核心概念

dataclass

Python3.7
版本开始,已经加入到标准库中了。

代码就在
Python
安装目录中的
Lib/dataclasses.py
文件中。

实现这个装饰器功能的核心有两个:
__annotations__
属性和
exec
函数。

2.1. __annotations__属性

__annotations__

Python
中一个隐藏的宝藏,它以字典的形式存储着变量、属性以及函数参数或返回值的类型提示。

对于
dataclass
来说,它就像是一张地图,装饰器通过它来找到用户定义的字段。

比如,在上面的
KLine
类中,
__annotations__
会返回字段的相关信息。

这使得
dataclass
装饰器能够清楚地知道类中包含哪些字段以及它们的类型,为后续的操作提供了关键信息。

if __name__ == "__main__":
    print(KLine.__annotations__)

# 运行结果:
{'name': <class 'str'>, 'open_price': <class 'float'>, 
'close_price': <class 'float'>, 'high_price': <class 'float'>, 
'low_price': <class 'float'>, 'begin_time': <class 'datetime.datetime'>}

2.2. exec 函数

exec
函数堪称
dataclass
实现的魔法棒,它能够将字符串形式的代码转换为
Python
对象。


dataclass
的世界里,它被用来创建各种必要的方法。

我们可以通过构建函数定义的字符串,然后使用
exec
将其转化为真正的函数,并添加到类中。

这就是
dataclass
装饰器能够自动生成
__init__

__repr__
等方法的秘密所在。

下面的代码通过
exec
,将一个字符串代码转换成一个真正可使用的函数。

# 定义一个存储代码的字符串
code_string = """
def greet(name):
    print(f"Hello, {name}!")
"""

# 使用 exec 函数执行代码字符串
exec(code_string)

# 调用通过 exec 生成的函数
greet("Alice")

3. 自定义dataclass装饰器

掌握了上面的核心概念,我们就可以开始尝试实现自己的
dataclass
装饰器。

当然,这里只是简单实现个雏形,目的是为了了解
Python
标准库中
dataclass
的原理。

下面主要实现两个功能
__init__

__repr__

通过这两个功能来理解
dataclass
的实现原理。

3.1. 定义架构

我们首先定义一个
dataclass
装饰器,它的结构如下:

def dataclass(cls=None, init=True, repr=True):

    def wrap(cls):
        # 这里将对类进行修改
        return cls

    if cls is None:
        return wrap
    return wrap(cls)

接下来,我们在这个装饰器中实现
__init__

__repr__

3.2. 初始化:
init


init
参数为
True
时,我们为类添加
__init__
方法。

通过
_init_fn
函数来实现,它会根据类的字段生成
__init__
方法的函数定义字符串,然后使用
_create_fn
函数将其转换为真正的方法并添加到类中。

def _create_fn(cls, name, fn):
    ns = {}
    exec(fn, None, ns)
    method = ns[name]
    setattr(cls, name, method)


def _init_fn(cls, fields):
    args = ", ".join(fields)

    lines = [f"self.{field} = {field}" for field in fields]
    body = "\n".join(f"  {line}" for line in lines)

    txt = f"def __init__(self, {args}):\n{body}"

    _create_fn(cls, "__init__", txt)

3.3. 美化输出:
repr

__repr__
方法让我们能够以一种清晰易读的方式打印出类的实例。

为了实现这个功能,我们创建
_repr_fn
函数,它生成
__repr__
方法的定义字符串。

这个方法会获取实例的
__dict__
属性中的所有变量,并使用
f-string
进行格式化输出。

def _repr_fn(cls, fields):
    txt = (
        "def __repr__(self):\n"
        "    fields = [f'{key}={val!r}' for key, val in self.__dict__.items()]\n"
        "    return f'{self.__class__.__name__}({\"\\n \".join(fields)})'"
    )
    _create_fn(cls, "__repr__", txt)

3.4. 合在一起

最终的代码如下,代码中使用的是自己的
dataclass
装饰器,而不是标准库中的
dataclass

from datetime import datetime


def dataclass(cls=None, init=True, repr=True):

    def wrap(cls):
        fields = cls.__annotations__.keys()

        if init:
            _init_fn(cls, fields)

        if repr:
            _repr_fn(cls, fields)

        return cls

    if cls is None:  # 如果装饰器带参数
        return wrap

    return wrap(cls)


def _create_fn(cls, name, fn):
    ns = {}
    exec(fn, None, ns)
    method = ns[name]
    setattr(cls, name, method)


def _init_fn(cls, fields):
    args = ", ".join(fields)

    lines = [f"self.{field} = {field}" for field in fields]
    body = "\n".join(f"  {line}" for line in lines)

    txt = f"def __init__(self, {args}):\n{body}"

    _create_fn(cls, "__init__", txt)


def _repr_fn(cls, fields):
    txt = (
        "def __repr__(self):\n"
        "    fields = [f'{key}={val!r}' for key, val in self.__dict__.items()]\n"
        "    return f'{self.__class__.__name__}({\"\\n \".join(fields)})'"
    )
    _create_fn(cls, "__repr__", txt)


@dataclass
class KLine:
    name: str = "BTC"
    open_price: float = 0.0
    close_price: float = 0.0
    high_price: float = 0.0
    low_price: float = 0.0
    begin_time: datetime = datetime.now()


if __name__ == "__main__":
    kl = KLine(
        name="ETH",
        open_price=1000.5,
        close_price=3200.5,
        high_price=3400,
        low_price=200,
        begin_time=datetime.now(),
    )
    print(kl)

运行的效果如下:

可以看出,我们自己实现的
dataclass
装饰器也可以实现类的初始化和美化输出,这里输出时每个属性占一行。

4. 总结

通过自定义
dataclass
装饰器的构建过程,我们深入了解了
Python

dataclass
的内部原理。

利用
__annotations__
获取字段信息,借助
exec
创建各种方法,从而实现简洁高效的
dataclass
定义。

不过,实际的
Python
标准库中的
dataclass
还有更多的功能和优化,了解了其原理之后,可以参考它的源码再进一步学习。

开心一刻

昨天在幼儿园,领着儿子在办公室跟他班主任聊他的情况
班主任:皓瑟,你跟我聊天是不是紧张呀
儿子:是的,老师
班主任:不用紧张,我虽然是你的班主任,但我也才22岁,你就把我当成班上的女同学
班主任继续补充道:你平时跟她们怎么聊,就跟我怎么聊,男孩子要果然,想说啥就说啥
儿子满眼期待的看向我,似乎在征询我的同意,我坚定的点了点头
儿子:老师,看看腿

开心一刻

问题复现

项目基于 Spring Boot 2.4.2,引入了
spring-boot-starter-data-redis

mybatis-plus-boot-starter
,完整依赖如下

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.4.2</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.0</version>
    </dependency>
    <!--mysql-->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
</dependencies>


RedisTemplate
进行了自定义配置

/**
 * @author 青石路
 */
@Configuration
public class RedisConfig {

    @Bean
    RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(factory);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer<Object> jsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(jsonRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(jsonRedisSerializer);
        redisTemplate.setEnableDefaultSerializer(true);
        redisTemplate.setDefaultSerializer(jsonRedisSerializer);
        redisTemplate.setEnableTransactionSupport(true);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

需要实现的功能

保存用户:若用户在缓存(
Redis
)中存在,直接返回成功;若用户在缓存中不存在,将用户信息保存到缓存的同时,还要保存到
MySQL

功能很简单,实现如下

/**
 * @author: 青石路
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserDao, User> implements IUserService {

    private static final Logger LOG = LoggerFactory.getLogger(UserServiceImpl.class);

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public String saveNotExist(User user) {
        Object o = redisTemplate.opsForValue().get("dataredis:user:" + user.getUserName());
        if (o != null) {
            LOG.info("用户已存在");
            return "用户已存在";
        }
        redisTemplate.opsForValue().set("dataredis:user:" + user.getUserName(), user);
        this.save(user);
        return "用户保存成功";
    }
}

结构还是常规的
Controller
->
Service
->
Dao
;启动项目后,我们直接访问接口

POST http://localhost:8080/user/save
Content-Type: application/json

{
  "userName": "qsl",
  "password": "123456"
}

毫无意外,接口 500

{
  "timestamp": "2024-12-28T05:39:49.577+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "message": "",
  "path": "/user/save"
}

这么简单的功能,这么完美的实现,为什么也出错?

早知道不学编程了

问题排查

遇到异常我们该如何排查?看
异常堆栈
是最直接的方式

异常堆栈信息

有两点值得我们好好分析下

  1. RedisConnectionUtils.createConnectionSplittingProxy

    看方法名就知道,这是要创建 Redis Connection 的代理;咱先甭管创建的是什么代理,咱先弄明白为什么要创建代理?


    不就是查 Redis,然后写 Redis,为什么要创建代理?


    怎么弄明白了,看谁调用了这个方法不就清楚了?直接从异常堆栈一眼就可以看出
    RedisConnectionUtils.java:151
    调用了该方法,我们点击跟进看看


    createConnectionSplittingProxy调用处

    所以重点有来到
    bindSynchronization

    isActualNonReadonlyTransactionActive()


    • bindSynchronization 的值

      它的计算逻辑很清楚


      TransactionSynchronizationManager.isActualTransactionActive() && transactionSupport;


      isActualTransactionActive() 注释如下

      /**
       * Return whether there currently is an actual transaction active.
       * This indicates whether the current thread is associated with an actual
       * transaction rather than just with active transaction synchronization.
       * <p>To be called by resource management code that wants to discriminate
       * between active transaction synchronization (with or without backing
       * resource transaction; also on PROPAGATION_SUPPORTS) and an actual
       * transaction being active (with backing resource transaction;
       * on PROPAGATION_REQUIRED, PROPAGATION_REQUIRES_NEW, etc).
       * @see #isSynchronizationActive()
       */
      public static boolean isActualTransactionActive() {
          return (actualTransactionActive.get() != null);
      }
      

      返回当前线程是否是与实际事务相关联;可能你们看的有点迷糊,因为这里还与 Spring 的事务传播机制有关联,结合我给的示例代码来看,可以简单理解成:
      当前线程是否开启事务


      saveNotExist开启事务

      很明显当前线程是开启事务的,所以 TransactionSynchronizationManager.isActualTransactionActive() 的值为
      true

      transactionSupport
      的值则需要继续从上游调用方寻找


      redis_trasactionSupportpng

      跟进
      RedisTemplate.java:209


      RedisTemplate_enableTrascationSupport

      enableTransactionSupport
      是 RedisTemplate 的成员变量,其默认值是
      false


      enableTransactionSupport默认值false

      但我们自定义的时候,将 enableTransactionSupport 设置成了
      true


      enableTransactionSupport自定义成true

      这里为什么设置成 true,我问了当时写这个代码的同事,直接从网上复制的,不是刻意开启的!
      我是不推荐使用 Redis 事务的,至于为什么,后文会有说明


      所以 bindSynchronization 的值为
      true

    • isActualNonReadonlyTransactionActive() 的返回值

      从名称就知道,该方法的作用是判断当前事务是不是
      非只读
      的;其完整代码如下

      private static boolean isActualNonReadonlyTransactionActive() {
          return TransactionSynchronizationManager.isActualTransactionActive()
                  && !TransactionSynchronizationManager.isCurrentTransactionReadOnly();
      }
      

      TransactionSynchronizationManager.isActualTransactionActive() 前面已经分析过,其值是
      true
      ;大家还记得事务设置只读是如何设置的吗?
      @Transactional
      注解是不是有
      readOnly
      配置项?


      @Transactional(rollbackFor = Exception.class, readOnly = true)


      readOnly 的默认值是
      false
      ,而我们的示例代码中又没有将其设置成 true,所以
      !TransactionSynchronizationManager.isCurrentTransactionReadOnly()
      的值就是
      !false
      ,也就是
      true

      所以 isActualNonReadonlyTransactionActive() 的值为
      true


    启用 RedisTemplate 事务的同时,又使用了 @Transactional 使得线程关联了实际事务,并且未启用非只读线程,天时地利人和之下创建了 Redis Connection 代理,通过该代理来实现 Redis 事务


    Spring 对事务的实现是通用的,都是通过代理的方式来实现,不区分是关系型数据库还是Redis,甚至是其他支持事务的数据源!

  2. cannot access its superinterface

    完整信息如下


    java.lang.IllegalAccessError: class org.springframework.data.redis.core.$Proxy82 cannot access its superinterface org.springframework.data.redis.core.RedisConnectionUtils$RedisConnectionProxy


    不合法的访问错误:不能访问父级接口:RedisConnectionUtils$RedisConnectionProxy


    关于 Spring 的代理,我们都知道有两种实现:
    JDK 动态代理

    CGLIB 动态代理
    ,而 Redis 事务则采用的 JDK 动态代理

    Redis事务实现_JDK代理

    JDK 动态代理有哪些限制,你们还记得吗,好好回忆一下


    RedisConnectionUtils$RedisConnectionProxy 都没有实现类,为什么代理会涉及到它?我们看下 RedisConnectionUtils.createConnectionSplittingProxy 的实现就明白了


    createConnectionSplittingProxy具体代码

    我们再看看 RedisConnectionUtils$RedisConnectionProxy 的具体实现


    RedisConnectionProxy_具体代码

    莫非是因为 RedisConnectionProxy 是内部 interface,并且是 package-protected 的,所以导致不能被访问?如何验证了,我们可以进行类似模拟,但我不推荐,我更推荐从官方找答案,因为这个问题肯定不止我们遇到了;从异常堆栈信息可以很明显的看出,这是
    spring-data-redis
    引发的,所以我们直接去其 github 寻找相关 issue


    RedisConnectionProxy_github搜索

    正好有一个,点进去看看,正好有我们想要的答案;推荐大家仔细看看这个 issue,我只强调一下重点


    issue_重点

    1. 将该bug添加到 2.4.7 版本中修复

    2. 将 RedisConnectionProxy 修改成 public

    3. 代码提交版本:503d639


      RedisConnectionProxy_修改记录

    官方 Release 版本也进行了说明


    官方release_2.4.7

至此,相信你们都清楚问题原因了

问题修复

既然问题已经找到,修复方法也就清晰了

  1. 启用只读事务

    这种方式只适用于部分特殊场景,因为它还影响关系型数据库的事务

    不推荐使用

  2. 停用 RedisTemplate 事务

    不设置 enableTransactionSupport,让其保持默认值
    false
    ,或者显示设置成
    false


    redisTemplate.setEnableTransactionSupport(false);


    还记不记得我前面跟你们说过:
    不推荐使用 Redis 事务
    ;至于为什么,我们来看看官网是怎么说明的


    redis事务不支持回滚

    Redis不支持事务回滚,因为支持回滚会对Redis的简单性和性能产生重大影响;Redis 事务只能保证两点


    • 事务中的所有命令都被序列化并按顺序执行。Redis执行事务期间,不会被其它客户端发送的命令打断,事务中的所有命令都作为一个隔离操作顺序执行
    • Redis事务是原子操作,或者执行所有命令或者都不执行。一旦执行命令,即使中间某个命令执行失败,后面的命令仍会继续执行

    另外,官网提到了一个另外一个点


    Redis脚本与事务

    Redis 脚本同样具有事务性。你可以用Redis事务做的一切,你也可以用脚本做,通常脚本会更简单、更快。但有些点我们需要注意,Redis 2.6.0 才引进脚本功能,Lua 脚本具备一定的原子性,可以保证隔离性,而且可以完美的
    支持后面的步骤依赖前面步骤的结果
    ,但同样也
    不支持回滚

    所以如果我们的 Redis 版本满足的话,推荐用 Lua 脚本而非 Redis 事务

    推荐使用

  3. 升级 spring-data-redis 版本

    spring-data-redis 2.4.7 实现了修复,但我们是采用的 starter 的方式引入的依赖,所以升级 spring boot 版本更合适;RedisConnectionUtils$RedisConnectionProxy 是 spring-data-redis 2.4.2 引入的,spring-boot-starter-data-redis 的版本与 spring-boot 版本一致,其 2.4.4、2.4.5 对应的 spring-data-redis 版本是 2.4.6、2.4.8,所以将 spring boot 升级到 2.4.5 或更高即可。如果可以的话,更推荐直接升级到适配 JDK 版本的最新稳定版本

    推荐使用

总结

  1. 异常堆栈就是发生异常时的调用栈,时间线顺序是
    从下往上
    ,也就是下面一行调用上面一行

  2. 如果Redis版本是2.6.0或更高,不推荐使用其事务功能,用Lua实现事务更合适

    不管是Redis事务,还是Lua脚本,都不支持事务回滚,所以我们要尽量保证Redis命令的正确使用

  3. 不管是使用 spring-data-redis 哪个版本,都推荐关闭 RedisTemplate 的 enableTransactionSupport

    出于两点考虑


    • 你们可以留意下你们项目中的 Redis 版本,肯定都高于 2.6.0,因为版本越高,功能越强大,性能越稳定;言外之意就是可以使用Lua脚本来实现事务
    • 需要用到Redis事务的场景很少,甚至没有;不怕你们笑话,我还没显示的使用过Redis的事务,当然间接用过(Redisson的锁用到了lua脚本)

前言

高阶组件HOC
在React社区是非常常见的概念,但是在Vue社区中却是很少人使用。主要原因有两个:1、Vue中一般都是使用SFC,实现HOC比较困难。2、HOC能够实现的东西,在Vue2时代
mixins
能够实现,在Vue3时代
Composition API
能够实现。如果你不知道HOC,那么你平时绝对没有场景需要他。但是如果你知道HOC,那么在一些特殊的场景使用他就可以很优雅的解决一些问题。

欧阳也在找工作,坐标成都求内推!

什么是高阶组件HOC

HOC使用场景就是
加强原组件

HOC实际就是一个函数,这个函数接收的参数就是一个组件,并且返回一个组件,返回的就是加强后组件。如下图:
hoc


Composition API
出现之前HOC还有一个常见的使用场景就是提取公共逻辑,但是有了
Composition API
后这种场景就无需使用HOC了。

高阶组件HOC使用场景

很多同学觉得有了
Composition API
后,直接无脑使用他就完了,无需费时费力的去搞什么HOC。那如果是下面这个场景呢?

有一天产品找到你,说要给我们的系统增加会员功能,需要让系统中的几十个功能块增加会员可见功能。如果不是会员这几十个功能块都显示成引导用户开通会员的UI,并且这些功能块涉及到几十个组件,分布在系统的各个页面中。

如果不知道HOC的同学一般都会这样做,将会员相关的功能抽取成一个名为
useVip.ts
的hooks。代码如下:

export function useVip() {
  function getShowVipContent() {
    // 一些业务逻辑判断是否是VIP
    return false;
  }

  return {
    showVipContent: getShowVipContent(),
  };
}

然后再去每个具体的业务模块中去使用
showVipContent
变量判断,
v-if="showVipContent"
显示原模块,
v-else
显示引导开通会员UI。代码如下:

<template>
  <Block1
    v-if="showVipContent"
    :name="name1"
    @changeName="(value) => (name1 = value)"
  />
  <OpenVipTip v-else />
</template>

<script setup lang="ts">
import { ref } from "vue";
import Block1 from "./block1.vue";
import OpenVipTip from "./open-vip-tip.vue";
import { useVip } from "./useVip";

const { showVipContent } = useVip();
const name1 = ref("block1");
</script>

我们系统中有几十个这样的组件,那么我们就需要这样去改几十次。非常麻烦,如果有些模块是其他同事写的代码还很容易改错!!!

而且现在流行搞SVIP,也就是光开通VIP还不够,需要再开通一个SVIP。当你后续接到SVIP需求时,你又需要去改这几十个模块。
v-if="SVIP"
显示某些内容,
v-else-if="VIP"
显示提示开通SVIP,
v-else
显示提示开通VIP。

上面的这一场景使用hooks去实现,虽然能够完成,但是因为入侵了这几十个模块的业务逻辑。所以容易出错,也改起来比较麻烦,代码也不优雅。

那么有没有一种更好的解决方案,让我们可以不入侵这几十个模块的业务逻辑的实现方式呢?

答案是:
高阶组件HOC

HOC的一个用途就是对组件进行增强,并且不会入侵原有组件的业务逻辑,在这里就是使用HOC判断会员相关的逻辑。如果是会员那么就渲染原本的模块组件,否则就渲染引导开通VIP的UI

实现一个简单的HOC

首先我们要明白Vue的组件经过编译后就是一个对象,对象中的
props
属性对应的就是我们写的
defineProps
。对象中的setup方法,对应的就是我们熟知的
<script setup>
语法糖。

比如我使用
console.log(Block1)
将上面的
import Block1 from "./block1.vue";
给打印出来,如下图:
console

这个就是我们引入的Vue组件对象。

还有一个冷知识,大家可能不知道。如果在setup方法中返回一个函数,那么在Vue内部就会认为这个函数就是实际的render函数,并且在setup方法中我们天然的就可以访问定义的变量。

利用这一点我们就可以在Vue3中实现一个简单的高阶组件HOC,代码如下:

import { h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    setup() {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent ? h(BaseComponent) : h(OpenVipTip);
      };
    },
  };
}

在上面的代码中我们将会员相关的逻辑全部放在了
WithVip
函数中,这个函数接收一个参数
BaseComponent
,他是一个Vue组件对象。


setup
方法中我们return了一个箭头函数,他会被当作render函数处理。

如果
showVipContent
为true,就表明当前用户开通了VIP,就使用
h
函数渲染传入的组件。

否则就渲染
OpenVipTip
组件,他是引导用户开通VIP的组件。

此时我们的父组件就应该是下面这样的:

<template>
  <EnhancedBlock1 />
</template>

<script setup lang="ts">
import Block1 from "./block1.vue";
import WithVip from "./with-vip.tsx";

const EnhancedBlock1 = WithVip(Block1);
</script>

这个代码相比前面的hooks的实现就简单很多了,只需要使用高阶组件
WithVip
对原来的
Block1
组件包一层,然后将原本使用
Block1
的地方改为使用
EnhancedBlock1
。对原本的代码基本没有入侵。

上面的例子只是一个简单的demo,他是不满足我们实际的业务场景。比如子组件有
props

emit

插槽
。还有我们在父组件中可能会直接调用子组件expose暴露的方法。

因为我们使用了HOC对原本的组件进行了一层封装,那么上面这些场景HOC都是不支持的,我们需要添加一些额外的代码去支持。

高阶组件HOC实现props和emit

在Vue中属性分为两种,一种是使用
props

emit
声明接收的属性。第二种是未声明的属性
attrs
,比如class、style、id等。

在setup函数中props是作为第一个参数返回,
attrs
是第二个参数中返回。

所以为了能够支持props和emit,我们的高阶组件
WithVip
将会变成下面这样:

import { SetupContext, h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,  // 新增代码
    setup(props, { attrs, slots, expose }: SetupContext) {  // 新增代码
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent
          ? h(BaseComponent, {
              ...props, // 新增代码
              ...attrs, // 新增代码
            })
          : h(OpenVipTip);
      };
    },
  };
}


setup
方法中接收的第一个参数就是
props
,没有在props中定义的属性就会出现在
attrs
对象中。

所以我们调用h函数时分别将
props

attrs
透传给子组件。

同时我们还需要一个地方去定义props,props的值就是直接读取子组件对象中的
BaseComponent.props
。所以我们给高阶组件声明一个props属性:
props: BaseComponent.props,

这样props就会被透传给子组件了。

看到这里有的小伙伴可能会问,那emit触发事件没有看见你处理呢?

答案是:我们无需去处理,因为父组件上面的
@changeName="(value) => (name1 = value)"
经过编译后就会变成属性:
:onChangeName="(value) => (name1 = value)"
。而这个属性由于我们没有在props中声明,所以他会作为
attrs
直接透传给子组件。

高阶组件实现插槽

我们的正常子组件一般还有插槽,比如下面这样:

<template>
  <div class="divider">
    <h1>{{ name }}</h1>
    <button @click="handleClick">change name</button>
    <slot />
    这里是block1的一些业务代码
    <slot name="footer" />
  </div>
</template>

<script setup lang="ts">
const emit = defineEmits<{
  changeName: [name: string];
}>();

const props = defineProps<{
  name: string;
}>();

const handleClick = () => {
  emit("changeName", `hello ${props.name}`);
};

defineExpose({
  handleClick,
});
</script>

在上面的例子中,子组件有个默认插槽和name为
footer
的插槽。此时我们来看看高阶组件中如何处理插槽呢?

直接看代码:

import { SetupContext, h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent
          ? h(
              BaseComponent,
              {
                ...props,
                ...attrs,
              },
              slots // 新增代码
            )
          : h(OpenVipTip);
      };
    },
  };
}

插槽的本质就是一个对象里面拥有多个方法,这些方法的名称就是每个具名插槽,每个方法的参数就是插槽传递的变量。这里我们只需要执行
h
函数时将
slots
对象传给h函数,就能实现插槽的透传(如果你看不懂这句话,那就等欧阳下篇插槽的文章写好后再来看这段话你就懂了)。

我们在控制台中来看看传入的
slots
插槽对象,如下图:
slots

从上面可以看到插槽对象中有两个方法,分别是
default

footer
,对应的就是默认插槽和footer插槽。

大家熟知h函数接收的第三个参数是children数组,也就是有哪些子元素。但是他其实还支持直接传入
slots
对象,下面这个是他的一种定义:

export function h<P>(
  type: Component<P>,
  props?: (RawProps & P) | null,
  children?: RawChildren | RawSlots,
): VNode

export type RawSlots = {
  [name: string]: unknown
  // ...省略
}

所以我们可以直接把slots对象直接丢给h函数,就可以实现插槽的透传。

父组件调用子组件的方法

有的场景中我们需要在父组件中直接调用子组件的方法,按照以前的场景,我们只需要在子组件中expose暴露出去方法,然后在父组件中使用ref访问到子组件,这样就可以调用了。

但是使用了HOC后,中间层多了一个高阶组件,所以我们不能直接访问到子组件expose的方法。

怎么做呢?答案很简单,直接上代码:

import { SetupContext, h, ref } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      // 新增代码start
      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );
      // 新增代码end

      return () => {
        return showVipContent
          ? h(
              BaseComponent,
              {
                ...props,
                ...attrs,
                ref: innerRef,  // 新增代码
              },
              slots
            )
          : h(OpenVipTip);
      };
    },
  };
}

在高阶组件中使用
ref
访问到子组件赋值给
innerRef
变量。然后expose一个
Proxy
的对象,在get拦截中让其直接去执行子组件中的对应的方法。

比如在父组件中使用
block1Ref.value.handleClick()
去调用
handleClick
方法,由于使用了HOC,所以这里读取的
handleClick
方法其实是读取的是HOC中expose暴露的方法。所以就会走到
Proxy
的get拦截中,从而可以访问到真正子组件中expose暴露的
handleClick
方法。

那么上面的Proxy为什么要使用
has
拦截呢?

答案是在Vue源码中父组件在执行子组件中暴露的方法之前会执行这样一个判断:

if (key in target) {
  return target[key];
}

很明显我们这里的
Proxy
代理的原始对象里面什么都没有,执行
key in target
肯定就是false了。所以我们可以使用
has
去拦截
key in target
,意思是只要访问的方法或者属性是子组件中
expose
暴露的就返回true。

至此,我们已经在HOC中覆盖了Vue中的所有场景。但是有的同学觉得
h
函数写着比较麻烦,不好维护,我们还可以将上面的高阶组件改为tsx的写法,
with-vip.tsx
文件代码如下:

import { SetupContext, ref } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );

      return () => {
        return showVipContent ? (
          <BaseComponent {...props} {...attrs} ref={innerRef}>
            {slots}
          </BaseComponent>
        ) : (
          <OpenVipTip />
        );
      };
    },
  };
}

一般情况下h函数能够实现的,使用
jsx
或者
tsx
都能实现(除非你需要操作虚拟DOM)。

注意上面的代码是使用
ref={innerRef}
,而不是我们熟悉的
ref="innerRef"
,这里很容易搞错!!

compose函数

此时你可能有个新需求,需要给某些模块显示不同的折扣信息,这些模块可能会和上一个会员需求的模块有重叠。此时就涉及到多个高阶组件之间的组合情况。

同样我们使用HOC去实现,新增一个
WithDiscount
高阶组件,代码如下:

import { SetupContext, onMounted, ref } from "vue";

export default function WithDiscount(BaseComponent: any, item: string) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const discountInfo = ref("");

      onMounted(async () => {
        const res = await getDiscountInfo(item);
        discountInfo.value = res;
      });

      function getDiscountInfo(item: any): Promise<string> {
        // 根据传入的item获取折扣信息
        return new Promise((resolve) => {
          setTimeout(() => {
            resolve("我是折扣信息1");
          }, 1000);
        });
      }

      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );

      return () => {
        return (
          <div class="with-discount">
            <BaseComponent {...props} {...attrs} ref={innerRef}>
              {slots}
            </BaseComponent>
            {discountInfo.value ? (
              <div class="discount-info">{discountInfo.value}</div>
            ) : null}
          </div>
        );
      };
    },
  };
}

那么我们的父组件如果需要同时用VIP功能和折扣信息功能需要怎么办呢?代码如下:

const EnhancedBlock1 = WithVip(WithDiscount(Block1, "item1"));

如果不是VIP,那么这个模块的折扣信息也不需要显示了。

因为高阶组件接收一个组件,然后返回一个加强的组件。利用这个特性,我们可以使用上面的这种代码将其组合起来。

但是上面这种写法大家觉得是不是看着很难受,一层套一层。如果这里同时使用5个高阶组件,这里就会套5层了,那这个代码的维护难度就是地狱难度了。

所以这个时候就需要
compose
函数了,这个是React社区中常见的概念。它的核心思想是将多个函数从右到左依次组合起来执行,前一个函数的输出作为下一个函数的输入。

我们这里有多个HOC(也就是有多个函数),我们期望执行完第一个HOC得到一个加强的组件,然后以这个加强的组件为参数去执行第二个HOC,最后得到由多个HOC加强的组件。

compose
函数就刚好符合我们的需求,这个是使用
compose
函数后的代码,如下:

const EnhancedBlock1 = compose(WithVip, WithDiscount("item1"))(Block1);

这样就舒服多了,所有的高阶组件都放在第一个括弧里面,并且由右向左去依次执行每个高阶组件HOC。如果某个高阶组件HOC需要除了组件之外的额外参数,像
WithDiscount
这样处理就可以了。

很明显,我们的
WithDiscount
高阶组件的代码需要修改才能满足
compose
函数的需求,这个是修改后的代码:

import { SetupContext, onMounted, ref } from "vue";

export default function WithDiscount(item: string) {
  return (BaseComponent: any) => {
    return {
      props: BaseComponent.props,
      setup(props, { attrs, slots, expose }: SetupContext) {
        const discountInfo = ref("");

        onMounted(async () => {
          const res = await getDiscountInfo(item);
          discountInfo.value = res;
        });

        function getDiscountInfo(item: any): Promise<string> {
          // 根据传入的item获取折扣信息
          return new Promise((resolve) => {
            setTimeout(() => {
              resolve("我是折扣信息1");
            }, 1000);
          });
        }

        const innerRef = ref();
        expose(
          new Proxy(
            {},
            {
              get(_target, key) {
                return innerRef.value?.[key];
              },
              has(_target, key) {
                return innerRef.value?.[key];
              },
            }
          )
        );

        return () => {
          return (
            <div class="with-discount">
              <BaseComponent {...props} {...attrs} ref={innerRef}>
                {slots}
              </BaseComponent>
              {discountInfo.value ? (
                <div class="discount-info">{discountInfo.value}</div>
              ) : null}
            </div>
          );
        };
      },
    };
  };
}

注意看,
WithDiscount
此时只接收一个参数
item
,不再接收
BaseComponent
组件对象了,然后直接return出去一个回调函数。

准确的来说此时的
WithDiscount
函数已经不是高阶组件HOC了,
他return出去的回调函数才是真正的高阶组件HOC
。在回调函数中去接收
BaseComponent
组件对象,然后返回一个增强后的Vue组件对象。

至于参数
item
,因为闭包所以在里层的回调函数中还是能够访问的。这里比较绕,可能需要多理解一下。

前面的理解完了后,我们可以再上一点强度了。来看看
compose
函数是如何实现的,代码如下:

function compose(...funcs) {
  return funcs.reduce((acc, cur) => (...args) => acc(cur(...args)));
}

这个函数虽然只有一行代码,但是乍一看,怎么看怎么懵逼,欧阳也是!!
我们还是结合demo来看:

const EnhancedBlock1 = compose(WithA, WithB, WithC, WithD)(View);

假如我们这里有
WithA

WithB

WithC

WithD
四个高阶组件,都是用于增强组件
View

compose中使用的是
...funcs
将调用
compose
函数接收到的四个高阶组件都存到了
funcs
数组中。

然后使用reduce去遍历这些高阶组件,注意看执行
reduce
时没有传入第二个参数。

所以第一次执行reduce时,
acc
的值为
WithA

cur
的值为
WithB
。返回结果也是一个回调函数,将这两个值填充进去就是
(...args) => WithA(WithB(...args))
,我们将第一次的执行结果命名为
r1

我们知道reduce会将上一次的执行结果赋值为acc,所以第二次执行reduce时,
acc
的值为
r1

cur
的值为
WithC
。返回结果也是一个回调函数,同样将这两个值填充进行就是
(...args) => r1(WithC(...args))
。同样我们将第二次的执行结果命名为
r2

第三次执行reduce时,此时的
acc
的值为
r2

cur
的值为
WithD
。返回结果也是一个回调函数,同样将这两个值填充进行就是
(...args) => r2(WithD(...args))
。同样我们将第三次的执行结果命名为
r3
,由于已经将数组遍历完了,最终reduce的返回值就是
r3
,他是一个回调函数。

由于
compose(WithA, WithB, WithC, WithD)
的执行结果为
r3
,那么
compose(WithA, WithB, WithC, WithD)(View)
就等价于
r3(View)

前面我们知道
r3
是一个回调函数:
(...args) => r2(WithD(...args))
,这个回调函数接收的参数
args
,就是需要增强的基础组件
View
。所以执行这个回调函数就是先执行
WithD
对组件进行增强,然后将增强后的组件作为参数去执行
r2

同样
r2
也是一个回调函数:
(...args) => r1(WithC(...args))
,接收上一次
WithD
增强后的组件为参数执行
WithC
对组件再次进行增强,然后将增强后的组件作为参数去执行
r1

同样
r1
也是一个回调函数:
(...args) => WithA(WithB(...args))
,将
WithC
增强后的组件丢给
WithB
去执行,得到增强的组件再丢给
WithA
去执行,最终就拿到了最后增强的组件。

执行顺序就是
从右向左
去依次执行高阶组件对基础组件进行增强。

至此,关于
compose
函数已经讲完了,这里对于Vue的同学可能比较难理解,建议多看两遍。

总结

这篇文章我们讲了在Vue3中如何实现一个高阶组件HOC,但是里面涉及到了很多源码知识,所以这是一篇运用源码的实战文章。如果你理解了文章中涉及到的知识,那么就会觉得Vue中实现HOC还是很简单的,反之就像是在看天书。

还有最重要的一点就是
Composition API
已经能够解决绝大部分的问题,只有少部分的场景才需要使用高阶组件HOC,
切勿强行使用HOC
,那样可能会有炫技的嫌疑。如果是防御性编程,那么就当我没说。

最后就是我们实现的每个高阶组件HOC都有很多重复的代码,而且实现起来很麻烦,心智负担也很高。那么我们是不是可以抽取一个
createHOC
函数去批量生成高阶组件呢?这个就留给各位自己去思考了。

还有一个问题,我们这种实现的高阶组件叫做
正向属性代理
,弊端是每代理一层就会增加一层组件的嵌套。那么有没有方法可以解决嵌套的问题呢?

答案是
反向继承
,但是这种也有弊端如果业务是setup中返回的render函数,那么就没法重写了render函数了。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

前言

sql优化是一个大家都比较关注的热门话题,无论你在面试,还是工作中,都很有可能会遇到。

如果某天你负责的某个线上接口,出现了性能问题,需要做优化。那么你首先想到的很有可能是优化sql语句,因为它的改造成本相对于代码来说也要小得多。

那么,如何优化sql语句呢?

这篇文章从15个方面,分享了sql优化的一些小技巧,希望对你有所帮助。

(我最近开源了一个基于 SpringBoot+Vue+uniapp 的商城项目,欢迎访问和star。)[
https://gitee.com/dvsusan/susan_mall
]

1 避免使用select *

很多时候,我们写sql语句时,为了方便,喜欢直接使用
select *
,一次性查出表中所有列的数据。

反例:

select * from user where id=1;

在实际业务场景中,可能我们真正需要使用的只有其中一两列。查了很多数据,但是不用,白白浪费了数据库资源,比如:内存或者cpu。

此外,多查出来的数据,通过网络IO传输的过程中,也会增加数据传输的时间。

还有一个最重要的问题是:
select *
不会走
覆盖索引
,会出现大量的
回表
操作,而从导致查询sql的性能很低。

那么,如何优化呢?

正例:

select name,age from user where id=1;

sql语句查询时,只查需要用到的列,多余的列根本无需查出来。

2 用union all代替union

我们都知道sql语句使用
union
关键字后,可以获取排重后的数据。

而如果使用
union all
关键字,可以获取所有数据,包含重复的数据。

反例:

(select * from user where id=1) 
union 
(select * from user where id=2);

排重的过程需要遍历、排序和比较,它更耗时,更消耗cpu资源。

所以如果能用union all的时候,尽量不用union。

正例:

(select * from user where id=1) 
union all
(select * from user where id=2);

除非是有些特殊的场景,比如union all之后,结果集中出现了重复数据,而业务场景中是不允许产生重复数据的,这时可以使用union。

3 小表驱动大表

小表驱动大表,也就是说用小表的数据集驱动大表的数据集。

假如有order和user两张表,其中order表有10000条数据,而user表有100条数据。

这时如果想查一下,所有有效的用户下过的订单列表。

可以使用
in
关键字实现:

select * from order
where user_id in (select id from user where status=1)

也可以使用
exists
关键字实现:

select * from order
where exists (select 1 from user where order.user_id = user.id and status=1)

前面提到的这种业务场景,使用in关键字去实现业务需求,更加合适。

为什么呢?

因为如果sql语句中包含了in关键字,则它会优先执行in里面的
子查询语句
,然后再执行in外面的语句。如果in里面的数据量很少,作为条件查询速度更快。

而如果sql语句中包含了exists关键字,它优先执行exists左边的语句(即主查询语句)。然后把它作为条件,去跟右边的语句匹配。如果匹配上,则可以查询出数据。如果匹配不上,数据就被过滤掉了。

这个需求中,order表有10000条数据,而user表有100条数据。order表是大表,user表是小表。如果order表在左边,则用in关键字性能更好。

总结一下:

  • in
    适用于左边大表,右边小表。
  • exists
    适用于左边小表,右边大表。

不管是用in,还是exists关键字,其核心思想都是用小表驱动大表。

4 批量操作

如果你有一批数据经过业务处理之后,需要插入数据,该怎么办?

反例:

for(Order order: list){
   orderMapper.insert(order):
}

在循环中逐条插入数据。

insert into order(id,code,user_id) 
values(123,'001',100);

该操作需要多次请求数据库,才能完成这批数据的插入。

但众所周知,我们在代码中,每次远程请求数据库,是会消耗一定性能的。而如果我们的代码需要请求多次数据库,才能完成本次业务功能,势必会消耗更多的性能。

那么如何优化呢?

正例:

orderMapper.insertBatch(list):

提供一个批量插入数据的方法。

insert into order(id,code,user_id) 
values(123,'001',100),(124,'002',100),(125,'003',101);

这样只需要远程请求一次数据库,sql性能会得到提升,数据量越多,提升越大。

但需要注意的是,不建议一次批量操作太多的数据,如果数据太多数据库响应也会很慢。批量操作需要把握一个度,建议每批数据尽量控制在500以内。如果数据多于500,则分多批次处理。

5 多用limit

有时候,我们需要查询某些数据中的第一条,比如:查询某个用户下的第一个订单,想看看他第一次的首单时间。

反例:

select id, create_date 
 from order 
where user_id=123 
order by create_date asc;

根据用户id查询订单,按下单时间排序,先查出该用户所有的订单数据,得到一个订单集合。 然后在代码中,获取第一个元素的数据,即首单的数据,就能获取首单时间。

List<Order> list = orderMapper.getOrderList();
Order order = list.get(0);

虽说这种做法在功能上没有问题,但它的效率非常不高,需要先查询出所有的数据,有点浪费资源。

那么,如何优化呢?

正例:

select id, create_date 
 from order 
where user_id=123 
order by create_date asc 
limit 1;

使用
limit 1
,只返回该用户下单时间最小的那一条数据即可。

此外,在删除或者修改数据时,为了防止误操作,导致删除或修改了不相干的数据,也可以在sql语句最后加上limit。

例如:

update order set status=0,edit_time=now(3) 
where id>=100 and id<200 limit 100;

这样即使误操作,比如把id搞错了,也不会对太多的数据造成影响。

6 in中值太多

对于批量查询接口,我们通常会使用
in
关键字过滤出数据。比如:想通过指定的一些id,批量查询出用户信息。

sql语句如下:

select id,name from category
where id in (1,2,3...100000000);

如果我们不做任何限制,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。

这时该怎么办呢?

select id,name from category
where id in (1,2,3...100)
limit 500;

可以在sql中对数据用limit做限制。

不过我们更多的是要在业务代码中加限制,伪代码如下:

public List<Category> getCategory(List<Long> ids) {
   if(CollectionUtils.isEmpty(ids)) {
      return null;
   }
   if(ids.size() > 500) {
      throw new BusinessException("一次最多允许查询500条记录")
   }
   return mapper.getCategoryList(ids);
}

还有一个方案就是:如果ids超过500条记录,可以分批用多线程去查询数据。每批只查500条记录,最后把查询到的数据汇总到一起返回。

不过这只是一个临时方案,不适合于ids实在太多的场景。因为ids太多,即使能快速查出数据,但如果返回的数据量太大了,网络传输也是非常消耗性能的,接口性能始终好不到哪里去。

7 增量查询

有时候,我们需要通过远程接口查询数据,然后同步到另外一个数据库。

反例:

select * from user;

如果直接获取所有的数据,然后同步过去。这样虽说非常方便,但是带来了一个非常大的问题,就是如果数据很多的话,查询性能会非常差。

这时该怎么办呢?

正例:

select * from user 
where id>#{lastId} and create_time >= #{lastCreateTime} 
limit 100;

按id和时间升序,每次只同步一批数据,这一批数据只有100条记录。每次同步完成之后,保存这100条数据中最大的id和时间,给同步下一批数据的时候用。

通过这种增量查询的方式,能够提升单次查询的效率。

8 高效的分页

有时候,列表页在查询数据时,为了避免一次性返回过多的数据影响接口性能,我们一般会对查询接口做分页处理。

在mysql中分页一般用的
limit
关键字:

select id,name,age 
from user limit 10,20;

如果表中数据量少,用limit关键字做分页,没啥问题。但如果表中数据量很多,用它就会出现性能问题。

比如现在分页参数变成了:

select id,name,age 
from user limit 1000000,20;

mysql会查到1000020条数据,然后丢弃前面的1000000条,只查后面的20条数据,这个是非常浪费资源的。

那么,这种海量数据该怎么分页呢?

优化sql:

select id,name,age 
from user where id > 1000000 limit 20;

先找到上次分页最大的id,然后利用id上的索引查询。不过该方案,要求id是连续的,并且有序的。

还能使用
between
优化分页。

select id,name,age 
from user where id between 1000000 and 1000020;

需要注意的是between要在唯一索引上分页,不然会出现每页大小不一致的问题。

9 用连接查询代替子查询

mysql中如果需要从两张以上的表中查询出数据的话,一般有两种实现方式:
子查询

连接查询

子查询的例子如下:

select * from order
where user_id in (select id from user where status=1)

子查询语句可以通过
in
关键字实现,一个查询语句的条件落在另一个select语句的查询结果中。程序先运行在嵌套在最内层的语句,再运行外层的语句。

子查询语句的优点是简单,结构化,如果涉及的表数量不多的话。

但缺点是mysql执行子查询时,需要创建临时表,查询完毕后,需要再删除这些临时表,有一些额外的性能消耗。

这时可以改成连接查询。 具体例子如下:

select o.* from order o
inner join user u on o.user_id = u.id
where u.status=1

10 join的表不宜过多

根据阿里巴巴开发者手册的规定,join表的数量不应该超过
3
个。

反例:

select a.name,b.name.c.name,d.name
from a 
inner join b on a.id = b.a_id
inner join c on c.b_id = b.id
inner join d on d.c_id = c.id
inner join e on e.d_id = d.id
inner join f on f.e_id = e.id
inner join g on g.f_id = f.id

如果join太多,mysql在选择索引的时候会非常复杂,很容易选错索引。

并且如果没有命中中,nested loop join 就是分别从两个表读一行数据进行两两对比,复杂度是 n^2。

所以我们应该尽量控制join表的数量。

正例:

select a.name,b.name.c.name,a.d_name 
from a 
inner join b on a.id = b.a_id
inner join c on c.b_id = b.id

如果实现业务场景中需要查询出另外几张表中的数据,可以在a、b、c表中
冗余专门的字段
,比如:在表a中冗余d_name字段,保存需要查询出的数据。

不过我之前也见过有些ERP系统,并发量不大,但业务比较复杂,需要join十几张表才能查询出数据。

所以join表的数量要根据系统的实际情况决定,不能一概而论,尽量越少越好。

11 join时要注意

我们在涉及到多张表联合查询的时候,一般会使用
join
关键字。

而join使用最多的是left join和inner join。

  • left join
    :求两个表的交集外加左表剩下的数据。
  • inner join
    :求两个表交集的数据。

使用inner join的示例如下:

select o.id,o.code,u.name 
from order o 
inner join user u on o.user_id = u.id
where u.status=1;

如果两张表使用inner join关联,mysql会自动选择两张表中的小表,去驱动大表,所以性能上不会有太大的问题。

使用left join的示例如下:

select o.id,o.code,u.name 
from order o 
left join user u on o.user_id = u.id
where u.status=1;

如果两张表使用left join关联,mysql会默认用left join关键字左边的表,去驱动它右边的表。如果左边的表数据很多时,就会出现性能问题。

要特别注意的是在用left join关联查询时,左边要用小表,右边可以用大表。如果能用inner join的地方,尽量少用left join。

12 控制索引的数量

众所周知,索引能够显著的提升查询sql的性能,但索引数量并非越多越好。

因为表中新增数据时,需要同时为它创建索引,而索引是需要额外的存储空间的,而且还会有一定的性能消耗。

阿里巴巴的开发者手册中规定,单表的索引数量应该尽量控制在
5
个以内,并且单个索引中的字段数不超过
5
个。

mysql使用的B+树的结构来保存索引的,在insert、update和delete操作时,需要更新B+树索引。如果索引过多,会消耗很多额外的性能。

那么,问题来了,如果表中的索引太多,超过了5个该怎么办?

这个问题要辩证的看,如果你的系统并发量不高,表中的数据量也不多,其实超过5个也可以,只要不要超过太多就行。

但对于一些高并发的系统,请务必遵守单表索引数量不要超过5的限制。

那么,高并发系统如何优化索引数量?

能够建联合索引,就别建单个索引,可以删除无用的单个索引。

将部分查询功能迁移到其他类型的数据库中,比如:Elastic Seach、HBase等,在业务表中只需要建几个关键索引即可。

13 选择合理的字段类型

char
表示固定字符串类型,该类型的字段存储空间的固定的,会浪费存储空间。

alter table order 
add column code char(20) NOT NULL;

varchar
表示变长字符串类型,该类型的字段存储空间会根据实际数据的长度调整,不会浪费存储空间。

alter table order 
add column code varchar(20) NOT NULL;

如果是长度固定的字段,比如用户手机号,一般都是11位的,可以定义成char类型,长度是11字节。

但如果是企业名称字段,假如定义成char类型,就有问题了。

如果长度定义得太长,比如定义成了200字节,而实际企业长度只有50字节,则会浪费150字节的存储空间。

如果长度定义得太短,比如定义成了50字节,但实际企业名称有100字节,就会存储不下,而抛出异常。

所以建议将企业名称改成varchar类型,变长字段存储空间小,可以节省存储空间,而且对于查询来说,在一个相对较小的字段内搜索效率显然要高些。

我们在选择字段类型时,应该遵循这样的原则:

  1. 能用数字类型,就不用字符串,因为字符的处理往往比数字要慢。
  2. 尽可能使用小的类型,比如:用bit存布尔值,用tinyint存枚举值等。
  3. 长度固定的字符串字段,用char类型。
  4. 长度可变的字符串字段,用varchar类型。
  5. 金额字段用decimal,避免精度丢失问题。

还有很多原则,这里就不一一列举了。

14 提升group by的效率

我们有很多业务场景需要使用
group by
关键字,它主要的功能是去重和分组。

通常它会跟
having
一起配合使用,表示分组后再根据一定的条件过滤数据。

反例:

select user_id,user_name from order
group by user_id
having user_id <= 200;

这种写法性能不好,它先把所有的订单根据用户id分组之后,再去过滤用户id大于等于200的用户。

分组是一个相对耗时的操作,为什么我们不先缩小数据的范围之后,再分组呢?

正例:

select user_id,user_name from order
where user_id <= 200
group by user_id

使用where条件在分组前,就把多余的数据过滤掉了,这样分组时效率就会更高一些。

其实这是一种思路,不仅限于group by的优化。我们的sql语句在做一些耗时的操作之前,应尽可能缩小数据范围,这样能提升sql整体的性能。

15 索引优化

sql优化当中,有一个非常重要的内容就是:
索引优化

很多时候sql语句,走了索引,和没有走索引,执行效率差别很大。所以索引优化被作为sql优化的首选。

索引优化的第一步是:检查sql语句有没有走索引。

那么,如何查看sql走了索引没?

可以使用
explain
命令,查看mysql的执行计划。

例如:

explain select * from `order` where code='002';

结果:

通过这几列可以判断索引使用情况,执行计划包含列的含义如下图所示:

如果你想进一步了解explain的详细用法,可以看看我的另一篇文章《
explain | 索引优化的这把绝世好剑,你真的会用吗?

说实话,sql语句没有走索引,排除没有建索引之外,最大的可能性是索引失效了。

下面说说索引失效的常见原因:

如果不是上面的这些原因,则需要再进一步排查一下其他原因。

此外,你有没有遇到过这样一种情况:明明是同一条sql,只有入参不同而已。有的时候走的索引a,有的时候却走的索引b?

没错,有时候mysql会选错索引。

必要时可以使用
force index
来强制查询sql走某个索引。

至于为什么mysql会选错索引,后面有专门的文章介绍的,这里先留点悬念。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

快速搭建 K8s 集群

角色 ip
k8s-master-01 192.168.111.170
k8s-node-01 192.168.111.171
k8s-node-02 192.168.111.172

服务器需要连接互联网下载镜像

软件 版本
Docker 24.0.0(CE)
Kubernetes 1.28

初始化配置

关闭防火墙

systemctl stop firewalld && systemctl disable firewalld

关闭Selinux

sed -i 's/enforcing/disabled/' /etc/selinux/config

setenforce 0

关闭Swap

sed -ri 's/.*swap.*/#&/' /etc/fstab

swapoff -a

根据规划设置主机名

hostnamectl set-hostname k8s-master-01
hostnamectl set-hostname k8s-node-01
hostnamectl set-hostname k8s-node-02

网络桥段

vi /etc/sysctl.conf

net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-arptables = 1
net.ipv4.ip_forward=1
net.ipv4.ip_forward_use_pmtu = 0

# 生效命令
sysctl --system 

# 查看效果
sysctl -a|grep "ip_forward"

确保网络桥接的数据包经过Iptables处理,防止网络丢包

cat > /etc/sysctl.d/k8s.conf << EOF
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
EOF

sysctl --system  # 生效

同步时间

# 安装软件
yum -y install ntpdate

# 向阿里云服务器同步时间
ntpdate time1.aliyun.com

# 删除本地时间并设置时区为上海
rm -rf /etc/localtime && ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

# 查看时间
date -R || date

开启 IPVS

yum -y install ipset ipvsdm

cat > /etc/sysconfig/modules/ipvs.modules << EOF
#!/bin/bash
modprobe -- ip_vs
modprobe -- ip_vs_rr
modprobe -- ip_vs_wrr
modprobe -- ip_vs_sh
modprobe -- nf_conntrack
EOF

# 赋予权限并执行
chmod 755 /etc/sysconfig/modules/ipvs.modules && bash /etc/sysconfig/modules/ipvs.modules &&lsmod | grep -e ip_vs -e  nf_conntrack_ipv4

# 重启电脑,检查是否生效
reboot

命令补全

yum -y install bash-completion bash-completion-extras

source /etc/profile.d/bash_completion.sh

配置 HOSTS

cat <<EOF >>/etc/hosts
192.168.111.170		k8s-master-01
192.168.111.171 	k8s-node-01
192.168.111.172 	k8s-node-02
EOF

cat <<EOF >>/etc/hosts
192.168.111.173 k8s-tool-01 harbor.liuyuncen.com
EOF

安装docker

下载源

yum install -y wget

wget https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo -O /etc/yum.repos.d/docker-ce.repo

安装 docker

# yum list docker-ce --showduplicates | sort -r

yum -y install docker-ce-24.0.0

systemctl enable docker && systemctl start docker

设置Cgroup驱动

cat > /etc/docker/daemon.json << EOF
{
  "exec-opts": ["native.cgroupdriver=systemd"]
}
EOF

systemctl daemon-reload && systemctl restart docker

# 查看设置状态
docker info

安装 cri-docker 驱动 (Docker与Kubernetes通信的中间程序):

# wget https://github.com/Mirantis/cri-dockerd/releases/download/v0.3.2/cri-dockerd-0.3.2-3.el7.x86_64.rpm

rpm -ivh cri-dockerd-0.3.2-3.el7.x86_64.rpm

指定依赖镜像地址为国内镜像地址:

vim /usr/lib/systemd/system/cri-docker.service

ExecStart=/usr/bin/cri-dockerd --container-runtime-endpoint fd:// --pod-infra-container-image=registry.aliyuncs.com/google_containers/pause:3.9

systemctl daemon-reload 
systemctl enable cri-docker && systemctl start cri-docker

部署 K8s 集群

添加阿里云 yum 源

cat > /etc/yum.repos.d/kubernetes.repo << EOF
[kubernetes]
name=Kubernetes
baseurl=https://mirrors.aliyun.com/kubernetes/yum/repos/kubernetes-el7-x86_64
enabled=1
gpgcheck=0
repo_gpgcheck=0
gpgkey=https://mirrors.aliyun.com/kubernetes/yum/doc/yum-key.gpg https://mirrors.aliyun.com/kubernetes/yum/doc/rpm-package-key.gpg
EOF

安装kubeadm 官方提供的集群搭建工具,kubelet 守护进程 和kubectl 管理集群工具(实际在 master 安装即可)

yum install -y kubelet-1.28.0 kubeadm-1.28.0 kubectl-1.28.0

systemctl enable kubelet
# 这里只是设置开机启动,直接起也起不来

提前把所有镜像都拉下来

docker load -i calico.v3.25.1.tar

只需要 k8s-master-01 节点执行
初始化Master节点

kubeadm init \
  --apiserver-advertise-address=192.168.126.170 \
  --image-repository registry.aliyuncs.com/google_containers \
  --kubernetes-version v1.28.0 \
  --service-cidr=10.96.0.0/12 \
  --pod-network-cidr=10.244.0.0/16 \
  --cri-socket=unix:///var/run/cri-dockerd.sock

初始化信息

mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
chown $(id -u):$(id -g) $HOME/.kube/config

只需要 k8s-node-01 、k8s-node-02 节点执行

--cri-socket=unix:///var/run/cri-dockerd.sock
这一句要补充在最后面

kubeadm join 192.168.111.170:6443 --token y8hujn.777t21thlk6v6hy0 --discovery-token-ca-cert-hash sha256:f8df8dfe6cb7ad5347f92b6c58f552df8982c7dce540b266c22f971e49f55684 --cri-socket=unix:///var/run/cri-dockerd.sock
kubeadm join 192.168.126.170:6443 --token puuo90.pqgbmu1d32x3vsq4 --discovery-token-ca-cert-hash sha256:3600ff21c5a3742fee0455d920f345ea0ab9c99f0356acf7675f2ee728448bff --cri-socket=unix:///var/run/cri-dockerd.sock

使用kubectl工具查看节点状态: kubectl get nodes 由于网络插件还没有部署,节点会处于“NotReady”状态

这里使用Calico作为Kubernetes的网络插件,负责集群中网络通信。创建Calico网络组件的资源

kubectl create -f tigera-operator.yaml
kubectl create -f custom-resources.yaml 

执行完成后,等待几分钟,执行

kubectl  get pods -n calico-system -o wide

问题排查,发现 nodes 无法启动,原因是下载不了镜像

kubectl describe po calico-kube-controllers-85955d4f5b-dbhhr -n calico-system
kubectl describe po calico-node-2sdxr -n calico-system
kubectl describe po calico-node-65gw4 -n calico-system
kubectl describe po calico-node-xqvnf -n calico-system

kubectl describe po calico-typha-7cd7bb8d58-lqmxj -n calico-system
kubectl describe po calico-typha-7cd7bb8d58-zwjd4 -n calico-system

kubectl describe po csi-node-driver-d9vkx -n calico-system
kubectl describe po csi-node-driver-nl26v -n calico-system
kubectl describe po csi-node-driver-qzljb -n calico-system
systemctl daemon-reload && systemctl restart docker
docker pull registry.cn-beijing.aliyuncs.com/yuncenliu/cni:v3.25.1-calico
docker tag registry.cn-beijing.aliyuncs.com/yuncenliu/cni:v3.25.1-calico docker.io/calico/pod2daemon-flexvol:v3.25.1

docker pull registry.cn-beijing.aliyuncs.com/yuncenliu/pod2daemon-flexvol:v3.25.1-calico
docker tag registry.cn-beijing.aliyuncs.com/yuncenliu/pod2daemon-flexvol:v3.25.1-calico docker.io/calico/cni:v3.25.1

重启containerd (有用)

systemctl restart containerd

删除失败的 pods

kubectl  get pods -n kube-system | grep calico-node-bvvhc   | awk '{print$1}'| xargs kubectl delete -n kube-system pods
docker save -o calico.v3.25.1.tar calico/kube-controllers:v3.25.1 calico/node:v3.25.1 calico/pod2daemon-flexvol:v3.25.1 calico/cni:v3.25.1 

docker load -i calico.v3.25.1.tar

全部启动成功,节点也全都在线 kubectl get nodes

安装页面

所有节点先下拉镜像

docker load -i k8s-dashboard-v2.7.0.tar

创建 Dashboard ,在 master 节点执行

kubectl apply -f kubernetes-dashboard.yaml
kubectl get pods -n kubernetes-dashboard -o wide

执行完成后,任意
https://任意node:30001
访问,例如:
https://192.168.111.170:30001/#/login

创建用户

kubectl create serviceaccount dashboard-admin -n kubernetes-dashboard

用户授权

kubectl create clusterrolebinding dashboard-admin --clusterrole=cluster-admin --serviceaccount=kubernetes-dashboard:dashboard-admin

获取用户 token

kubectl create token dashboard-admin -n kubernetes-dashboard

将 token 粘贴到浏览器的 token输入栏中(默认还是选第一项)点击登录即可