Elasticsearch Percolate Query使用优化案例-从2000到500ms

发布于
分类 数据库
标签 Elasticsearch

1. 前情提要

组内在某个项目中使用了ES较为冷门的Percolate Query能力,然后在数据累积到一定量级后遇到性能瓶颈,遂开始了漫漫的性能优化之路。优化过程中发现社区中针对Percolate Query优化的文章还是比较少见的,甚者全知全能的AI之神在我优化过程中都没有提供多少有建设性的指导意见,因此现将曲折的优化过程总结成本文分享给各位同学,希望对各位有帮助,也算给AI之神回馈一些训练数据了。

2. Percolate Query 简介

为了对齐认知这里还是先简单介绍下ES的Percolate Query能力,在Elasticsearch中,percolate query是一种特殊的查询类型,用于提前准备和匹配文档。它允许你将query存储在索引中,然后在新document到达时进行匹配,以查看哪些query与该document匹配。也就是说普通查询是以query查有哪些document匹配,而Percolate Query是以document查询哪些query匹配,两者正好相反。

2.1 使用场景

我们的使用场景是用户创建他们感兴趣的站内内容的查询,当新的内容发布或者更新时,使用Percolate Query查询到哪些用户对该内容感兴趣,然后分发给这批用户

2.2 实现步骤

  1. 创建查询索引

首先,我们创建一个索引来存储用户的查询。这个索引通常包含查询的结构和相关的元数据。

PUT /user_dsl
{
 "mappings": {
   "properties": {
     "query": {
       "type": "percolator"
     },
     "user_id": {
       "type": "keyword"
     }
   }
 }
}
  1. 添加查询 然后,将用户的查询添加到这个索引中。
PUT /user_dsl/_doc/1
{
  "query": {
    "bool": {
      "must": [{
        "term": {
          "article_type": 3
        }
      },
      {
        "nested": {
          "path": "categorys",
          "query": {
            "terms": {
              "categorys.id": [1, 2, 3]
            }
          }
        }
      },
      {
        "nested": {
          "path": "tags""query": {
            "terms": {
              "tags.id": [4, 5, 6]
            }
          }
        }
      },
      {
        "nested": {
          "path": "brands",
          "query": {
            "term": {
              "brands.id": "1673"
            }
          }
        }
      },
      {
        "bool": {
          "minimum_should_match": 1,
          "should": [{
            "match": {
              "content.ik_smart": {
                "minimum_should_match": "100%",
                "query": "惠普笔记本电脑"
              }
            }
          },
          {
            "match": {
              "title.ik_smart": {
                "minimum_should_match": "100%",
                "query": "惠普笔记本电脑"
              }
            }
          },
          {
            "match": {
              "content.ik_smart": {
                "minimum_should_match": "100%",
                "query": "笔记本 惠普"
              }
            }
          },
          {
            "match": {
              "title.ik_smart": {
                "minimum_should_match": "100%",
                "query": "笔记本 惠普"
              }
            }
          }]
        }
      }]
    }
  },
  "user_id": "123456"
}

  1. 使用percolate query

当新的内容发布或者更新时,使用percolate query来检查哪些用户的查询匹配这篇文章。

GET /user_dsl/_search
{
  "size": "1000",
  "_source": {
    "includes": ["user_id"]
  },
  "query": {
    "bool": {
      "must": [
        {
          "percolate": {
            "field": "query",
            "document": {
              "article_type": "3",
              "brands": [{"id":1,"name":"DANISH CROWN/丹尼斯皇冠"}],
              "tags": [{"id":1,"name":"玩具"},{"id":2,"name":"宠物"}],
              "categorys": [{"id":1,"name":"宠物"},{"id":2,"name":"宠物玩具"}],
              "content": "作为猫主人,您一定希望您的猫咪每天都能快乐、健康地生活。猫咪天性好动,喜欢探索和玩耍,同时也需要舒适的环境来满足它们的日常需求。今天,我们向您推荐一款能够极大提升猫咪幸福感的产品——猫咪风车蹭痒玩具。这款玩具不仅能让您的猫咪玩得开心,还能满足它们的蹭痒需求,是每一位猫主人必备的神器。 一、什么是猫咪风车蹭痒玩具? 猫咪风车蹭痒玩具是一款专为猫咪设计的多功能玩具,结合了风车转动和蹭痒功能。它由高质量",
              "title": "什么是猫咪风车蹭痒玩具"
            }
          }
        }
      ]
    }
  }
}

这个查询将返回所有匹配的query,包括用户ID,然后将这篇内容分发给这批用户即可

3. 问题与优化过程

随着用户创建的查询越来越多,我们发现站内内容分发越来越慢,并且在内容高频发布的时间段出现了大量积压,这严重影响了分发的实时性。通过trace平台我们发现分发就慢在ES percolate query上,如下图所示

  • /_search?scroll=1m查询(创建并得到ScrollID),这一步平均响应时间到达了2s多 image.png

  • /_search/scroll根据ScrollID查询继续往下迭代查询,这一步平均响应时间到了1s多 image.png

考虑到用户创建的查询很多,因此我们并不是通过一个_search查询直接拿到全部结果,而是通过scroll查询分批取结果

3.1 慢查询分析

找到一个慢查询后,在查询中增加"profile": true的选项后拿到了分析结果,如下所示

GET /user_dsl/_search
{
  "profile": true, // 增加这个选项
  "query": {
    "bool": {
      "must": [
        {
          "percolate": {
            "field": "query",
            "document": {
              xxx
            }
          }
        }
      ]
    }
  }
}

从结果中我们看到了这段失败的信息:

{
  "_scroll_id": "xxxxxx",
  "took": 4336,
  "timed_out": false,
  "_shards": {
    "total": 6,
    "successful": 2,
    "skipped": 0,
    "failed": 4,
    "failures": [
      {
        "shard": 0,
        "index": "user_dsl",
        "node": "xxxxxx",
        "reason": {
          "type": "too_many_scroll_contexts_exception",
          "reason": "Trying to create too many scroll contexts. Must be less than or equal to: [500]. This limit can be set by changing the [search.max_open_scroll_context] setting."
        }
      }
    ]
  },
  ...
}

在6个分片其中的4个分片上竟然失败了,失败的原因是无法创建更多的scroll contexts,查询项目代码后发现是最初的开发同学执行scroll查询后没有及时清理scroll context,导致了scroll context泄露,修复后我们发现响应时间明显降低:

  • 同一时间段/_search?scroll=1m查询,平均响应时间降低了700ms image.png
  • 同一时间段/_search/scroll查询,平均响应时间也降低了1s image.png

调用curl -X DELETE "http://{esHost}/_search/scroll" -d "{"scroll_id": "{scrollID}"}" 来回收创建的scroll context

完成这步后虽然响应时间得到了提升,但只是修复了bug,从profile结果中也无法看出有什么其他可以提升的地方,因此下一步准备从percolate query 原理入手看下还能如何提升。

3.2 percolate query 原理

从ES的官方文档 Percolate query | Reference 可以分析出percolate query大概的底层运行原理

当写入document到已配置了percolator字段类型的索引时,document的查询部分会被解析,从中提取数据然后建立索引。查询时首先利用索引粗筛出一批候选集,然后在内存中再精准的判断是否确实符合筛选条件。

可以发现这个过程候选集越大,需要在内存中比对的数据量就越大,越耗CPU,因此优化的核心思想就是减小候选集。

3.3 优化方案

根据上面的原理,我快速整理出了以下优化方案

3.3.1 提取必要条件【短期方案】

从生产环境用户query索引中可以看到大量query是符合以下模版的简单查询

SELECT *
FROM article
WHERE article_type = 1
        AND category_id IN (1, 2)
        AND brand_id = 3
        AND content LIKE '%回音壁%'
        AND tag_id = 6

这里为方便阅读使用sql表示

我们接着做如下分析,假设现有一篇文章数据为

{
    "article_type": 3,
    "article_id": 1,
    "category_ids": [1,2,3],
    ...
}

有两个查询为

  • sql1
SELECT *
FROM article
WHERE category_id IN (4, 5, 6) AND ......
  • sql 2
SELECT *
FROM article
WHERE category_id IN (1, 2, 3) AND ......

对这篇文章进行percolate时因为分类不符合所以肯定无法匹配到sql1的因此无需对sql1进行匹配。

利用如上原理,我们为每个查询提取出它的必要条件包括文章类型、分类、品牌,然后增加前置filter即可降低percolate query查询的规模。

以上方案上线后响应时间明显降低

  • /_search?scroll=1m查询,平均响应时间降低了非常多 image.png
  • /_search/scroll查询,平均响应时间也降低了非常多 image.png

不加入其他条件比如标签的原因是加入这种条件的查询比较少,过滤率比较低

3.3.2 批量查询【长期方案】

ES 文档 Percolating multiple documents 提到了percolate query支持批量查询。可以预见的是,如果多篇文章比较类似,那么它们的候选集大概率重合度会非常高,假设文章A候选集为[1,2,3],文章B候选集[1,2,6],如果分两次查询的话,内存中需要比对3 + 3 = 6次,而如果合并批量查询的话只需要比对[1,2,3,6] = 4次,这样就降低了CPU使用率,同时也能缩短整体的耗时

这个方案对于我们来说代码层面改动比较大,准备作为长期方案后续优化,因此暂时没有优化效果数据

3.3.3 冷热分离【长期方案】

核心思想是对于长时间不活跃的用户我们没必要给Ta分发,因此可以给每个query增加一个最后在线时间的字段,执行percolate query的filter中前置过滤出最近N天活跃的数据,从而降低percolate query查询的规模。而且这个N可以做成动态的,根据负载动态变化,当负载大积压数据多时可以适当缩小N从而能获得更快的消费分发速度,可以在突增流量到来时保护我们的系统。

由于这个方案对业务有影响,因此可以在评估影响范围后作为一个长期方案来做。

4. 总结

性能优化的套路大体上是相通的,第一步一定要先分析问题的出现原因,不能想当然,也不能靠猜测,无法找到原因的情况下再根据经验做一些推测然后做小成本的实验来验证。另外就是要充分利用好各种profile工具,在本次优化过程中ES profile就成功帮我们找到了一个代码上的bug。

5. 参考资料

...

阅读全文 »

算法不匹配导致ssh登录失败问题的解决

发布于
分类 linux
标签 linux

在家里macbook通过ssh登录我的服务器时发现登录失败,报错如下:

{user}@{ip}: Permission denied (publickey,gssapi-keyex,gssapi-with-mic).

奇怪的是我记得之前登录是正常的🙃

在ssh命令后加上-v后发现其中有一条信息:

$ ssh -p 22 {user}@{ip} -v
...
debug1: send_pubkey_test: no mutual signature algorithm
...

这意味着客户端和服务器之间没有共同支持的签名算法。其他机器登录正常,手动编辑~/.ssh/config文件增加如下内容确保客户端也使用 ssh-rsa 算法:

Host {}
    Port 22
    HostkeyAlgorithms +ssh-rsa
    PubkeyAcceptedAlgorithms +ssh-rsa

再次尝试后登录成功,问题解决✌🏻

...

阅读全文 »

我的家庭AIO服务器方案

发布于
分类 网络

618期间给家里换了个雷鸟的电视,想着给小孩看纪录片用。既然播放设备有了那还得有个纪录片存储的设备,所以就本着够用就好的原则在闲鱼捡了套垃圾DIY了一台飞牛NAS,硬件配置如下

  • CPU:NAS神U 奔腾G4560
  • 主板:梅捷H110M
  • 散热器:卖家送的垃圾散热器
  • 电源:卖家送的垃圾电源180W
  • 内存:多多20买的杂牌8G
  • 机箱:多多20多买的铁皮机箱
  • 网卡:主板自带的为百兆网卡,自己加了块之前买的2.5G网卡,型号为8125B

之所以要选G4560这颗U呢,首先因为它便宜,才10元左右,另外功耗比较低,核显为HD 610,支持硬解的格式比较多

image.png

结构

image.png

远程开机

因为飞牛NAS上没有部署需要24h跑的作业,仅仅用来作为下载机下载一些电影或者资源和备份相册,所以平时都会关机,只有要使用时才会开机。但是又有需要在外远程开机的诉求,因此我在家里找到了一台闲置多年的荣耀手机,安装了termux,然后在上面部署了一个我用Go写的IOT程序,这个程序会与巴法云建立连接,监听来自巴法云的开机消息,收到开机消息后会向内网的飞牛主机发送网络唤醒魔术包(即WOL)来唤醒飞牛主机。

我在我的阿里云服务器上部署了一个web服务,在外可以随时通过web服务向巴法云发送开机消息,这样就达到了在外远程开机的效果。

公网IP获取

开机后就要想办法能够连接到家里的NAS主机。在没有公网IP的情况下只能通过类似于frp这样的工具做内网穿透,这种方案的原理是通过一个公网的服务器进行转发,效率不高。

幸运的是我家联通宽带有公网IP,只需要将现有的通过光猫拨号修改为路由器拨号即可。不过这需要你拿到光猫的超管密码,具体的获取方式我是参考的这篇博客:《中国联通光猫G-140W-UG超管密码获取》,有需要的同学可以参考自取。拿到光猫超管密码后进到光猫后台和路由器后台一通配置即可,具体的配置步骤网上有很多这里我就不过多赘述了。

家宽的IP是会动态变化的,所以我们需要通过DDNS来将一个固定的域名解析到动态的公网IP上。恰好我的域名采购自腾讯云,使用的是DNSPOD作为域名解析服务商,而我的中兴路由器支持DNSPOD作为DDNS的服务商,所以DDNS的配置对于我来说还是非常简单方便的。

wireguard回家

拿到公网IP后我们就有了连接到家里NAS的基础条件,那么要连接到家里NAS一个最直接的方案就是暴露出飞牛的端口号到公网,但是这个方案有两个风险:

  • 安全问题,暴露到公网意味都任何人都能访问我们的NAS,纯靠账号密码进行安全验证的方式对于我来说还是有些大胆了
  • 从一些社区和论坛得知某些地域运营商不允许架设web server,发现后会进行封禁

我采用的方案是在NAS中通过docker部署wireguard来架设VPN服务,通过VPN达到“回家”的目的。我实际使用的是github.com/wg-easy/wg-easy这个项目来架设的wireguard服务,部署简单管理也非常简单。

监控

作为一个后端工程师,不能实时观测到服务器的状态始终让人感觉到不太可靠,虽然飞牛APP上提供了一些监控指标,但是没有提供web界面。因此我在NAS上通过docker部署了一个galance服务,然后将其web监控页面通过iframe嵌套到了我用AI帮我做的一个AIO导航页中,整体效果如下:

image.png

...

阅读全文 »

一个死锁导致的invalid connection问题排查

发布于
分类 数据库
标签 Go
标签 Mysql

1. 背景

上周社区业务的开发同学反馈他负责的Go应用请求Mysql时不时会报错invalid connection,一直排查不到原因,因此我帮他排查了下,最终发现是一个比较典型的死锁问题,因此记录下排查过程与思路。

首先看下监控报错:

image.png

涉及到的SQL:

UPDATE `article_meta` SET `meta_id`=?,`meta_value`=?,`update_time`=? WHERE `meta_id` = ?

表结构

CREATE TABLE `article_meta` (
  `meta_id` bigint(20) NOT NULL AUTO_INCREMENT,
  `article_id` bigint(20) unsigned NOT NULL DEFAULT '0',
  `meta_key` varchar(255) DEFAULT NULL,
  `meta_value` mediumtext,
  `update_time` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '更新时间',
  PRIMARY KEY (`meta_id`),
  KEY `article_id` (`article_id`),
  KEY `meta_key` (`meta_key`)
) ENGINE = InnoDB

2. 排查思路

2.1 根因分析

invalid connection这个报错信息上首先猜测是Mysql连接的问题,因此到database/sql包下看看哪些位置会报这个错误,通过全文检索并没有发现有这个错误msg。那么继续到Mysql的驱动go-sql-driver/mysql包中查询,最终发现了可能报这个错误的位置大概有两处:

  1. 事务提交和回滚时如果连接已关闭会报这个错误 image.png
  2. 读包时遇到错误(连接被关闭、超时)会报这个错误 image.png

结合监控上的报错信息与业务同学配置的readTimeout超时时间基本确认是SQL执行超时导致了readPacket()抛出的错误。

image.png

另外readPacket()函数中抛出ErrInvalidConn之前会调用mc.log(err)打印错误信息,打日志实际是使用了defaultLogger打印到了标准输出中:

image.png

查询Pod的标准输出之后确实看到了如下输出,进一步佐证了上述的结论

image.png

2.2 慢SQL分析

那么我们再回头看执行的SQL,分析下执行慢SQL的原因

UPDATE `article_meta` SET `meta_id`=?,`meta_value`=?,`update_time`=? WHERE `meta_id` = ?

结合表结构可以知道meta_id是主键id,那么这个SQL执行慢不太可能是查询行数据导致的,那么只剩下一种可能:这个SQL在等待锁释放。

SQL执行超过了3000ms,意味着可能存在一个慢事务,锁住该行后执行超过了3000ms,但询问业务同学后得知更新这个表的只有这一个位置。

这时我们再review遍业务代码,尝试从业务代码中找到写蛛丝马迹,代码精简后大概是如下逻辑:

var articleMetaUpdateMap map[string]*UpdateData
...
tx := db.Begin()
for _, value := range articleMetaUpdateMap {
	MetaID := value.MetaID
	err = tx.Table("article_meta").Where("meta_id = ? ", MetaID).Updates(value).Error
	if err != nil {
		tx.Rollback()
		return
	}
}

由于Go map的元素遍历随机性,这个代码逻辑可能会造成如下的执行顺序 | 事务A | 事务B | | - | - | | Update article_meta value=x where meta_id = 1 (锁定了id=1的行)| | | | Update article_meta value=x where meta_id = 2 (锁定了id=2的行)| | Update article_meta value=x where meta_id = 2 (locked,等待事务B释放id=2行锁) | | | | Update article_meta value=x where meta_id = 1 (locked,等待事务A释放id=1行锁) |

好,两个事务互相等待对方释放行锁,一个典型的死锁~

2.3 一些问题

  1. 那么为什么Mysql检测到死锁呢?

很简单,这个业务库没有开启死锁检测

> SHOW VARIABLES LIKE 'innodb_deadlock_detect';
Variable_name | Value
innodb_deadlock_detect | OFF
  1. 通过SHOW FULL PROCESSLIST查询运行中的线程是不是应该观测到两个线程?

答案是观测不到的,原因是readPacket()超时后会主动关闭连接

data, err := mc.buf.readNext(4)
if err != nil {
    ...
	mc.log(err)
	mc.Close() // 关闭了连接
	return nil, ErrInvalidConn
}

3. 修复方案

在遍历要更新的数据即articleMetaUpdateMap这个map之前,将其转成数组,然后按照主键Id排个序(正序倒序都可),这样可以保证两个事务以相同的上锁顺序进行更新,避免了死锁。

...

阅读全文 »

Mysql身份认证过程

发布于
分类 数据库
标签 Mysql

背景

最近有一些hersql的用户希望能支持mysql的caching_sha2_password认证方式,caching_sha2_password与常用的mysql_native_password认证过程差异还是比较大的,因此抽空研究了一下caching_sha2_password身份认证过程,并为hersql支持了caching_sha2_password的能力

hersql是我开源的一款通过http隧道来代理mysql的工具,可以通过http服务来穿透内网的mysql server,地址:github.com/Orlion/hersql

mysql身份认证过程

image.png

Client与Server建立TCP连接后,Server返回Initial Handshake Packet,这个包中会携带Server默认的认证方式,因为此时还不清楚登录用户是谁,所以是无法返回准确的认证方式的。

mysql8.0这个值默认值为caching_sha2_password,低版本为mysql_native_password

Client会先以Server返回的认证方式对密码进行加密,然后通过Handshake Response Packet发送给Server,这一轮交互完成后接下来会存在三种case:

  1. 认证失败。比如密码错误。
  2. 认证成功。成功建立了连接,接下来可以进行命令通信。
  3. 返回AuthMoreData包,这时又分为两种情况:
    • 包第二个字节 = 0x03,随后是一个正常的 OK 数据包,这是当用户的密码已在Server缓存中并且身份验证已成功时的情况,这种称之为“fast” authentication
    • 包第二个字节 = 0x04,这意味着需要更多数据才能完成身份验证,在使用caching_sha2_password 认证方式时,这意味着用户密码不在Server缓存中,Server要求Client发送用户的完整密码,这就是所谓的“full” authentication。这时Client需要用Server的公钥对密码进行加密然后再次发送给Server。
  4. 返回auth switch”包。Server收到Handshake Response Packet后会查询登录用户的认证方式,如果首次认证使用的认证方式与用户指定的认证方式不同,需要进行切换,会在auth switch包中携带准确的认证方式。接下来Client要用Server返回的这个准确的认证方式重新发起一轮认证请求。

mysql_native_password

mysql_native_password 身份验证插件从 MySQL 8.0.34 开始已弃用,在 MySQL 8.4 中默认禁用,并从 MySQL 9.0.0 开始删除。

用户密码存储在mysql.userauthentication_string字段中。在mysql_native_password认证方式下Server端存储的用户密码为原始密码经过两个sha1后的哈希值,没有经过加盐,因此相同的密码存储的值是相同的。

通讯过程简析

Server端会在Initial Handshake Packet返回一个随机数,Client收到之后首先与Server相同的对原始密码进行两次sha1,然后把Server返回的随机数加到摘要中,最终进行一个异或运算,得到最终的认证字符串:

// Hash password using 4.1+ method (SHA1)
func scramblePassword(scramble []byte, password string) []byte {
	if len(password) == 0 {
		return nil
	}

	// stage1Hash = SHA1(password)
	crypt := sha1.New()
	crypt.Write([]byte(password))
	stage1 := crypt.Sum(nil)

	// scrambleHash = SHA1(scramble + SHA1(stage1Hash))
	// inner Hash
	crypt.Reset()
	crypt.Write(stage1)
	hash := crypt.Sum(nil)

	// outer Hash
	crypt.Reset()
	crypt.Write(scramble)
	crypt.Write(hash)
	scramble = crypt.Sum(nil)

	// token = scrambleHash XOR stage1Hash
	for i := range scramble {
		scramble[i] ^= stage1[i]
	}
	return scramble
}

Client通过Handshake Response Packet发送给Server,Server采用与Client相同的算法生成认证字符串,如果两端生成的一致则说明密码正确,认证通过。

caching_sha2_password

这种认证方式下存储在mysql.userauthentication_string字段中值为:

image.png

即利用盐值进行5000轮SHA256哈希。

通讯过程简析

同样Server端会先返回一个随机数,Client生成认证字符串的算法为XOR(SHA256(password), SHA256(SHA256(SHA256(password)), scramble))。Server端收到Handshake Response Packet之后首先会检查username/SHA256(SHA256(user_password)) 是否与缓存匹配,如果匹配则认证成功。如果没有匹配的缓存则则要求Client通过SSL连接或者RSA公钥对密码进行加密后再次发送给Server端,Server解密后获取到密码明文然后得到哈希值判断密码是否正确。

...

阅读全文 »

使用AVX2指令集加速推荐系统MMR层余弦相似度计算

发布于
分类 汇编
标签 Go

1. 背景

前一段时间公司上线了一套Go实现的推荐系统,上线后发现MMR层虽然只有纯计算但耗时十分离谱,通过pprof定位问题所在之后进行了优化,虽然降低了非常多但是我们认为其中还有优化空间。

image.png

可以看到日常平均耗时126ms,P95 360ms。

MMR层主要耗时集中在了余弦相似度的计算部分,这部分我们使用的gonum库进行计算,其底层在x86平台上利用了SSE指令集进行了加速。

SSE指令集已经非常古老了,xmm寄存器只能存储两个双精度浮点数,每次只能并行进行两个双精度浮点数的计算,而AVX2指令集可以并行计算四个,理论上可以获得两倍的性能提升,因此我们决定自己使用AVX2指令集手写汇编的方式替代掉gonum库。

1.1 余弦相似度算法

余弦相似度的计算公式为

image.png

对应的代码为

import "gonum.org/v1/gonum/floats"

func CosineSimilarity(a, b []float64) float64 {
    dotProduct := floats.Dot(a, b) // 计算a和b的点积
    normA := floats.Norm(a, 2) // 计算向量a的L2范数
    normB := floats.Norm(b, 2) // 计算向量b的L2范数
    return dotProduct / (normA * normB)
}

2. Dot点积计算加速

gonum点积计算Dot的部分汇编代码如下:

TEXT ·DotUnitary(SB), NOSPLIT, $0
    ...
loop_uni:
	// sum += x[i] * y[i] unrolled 4x.
	MOVUPD 0(R8)(SI*8), X0
	MOVUPD 0(R9)(SI*8), X1
	MOVUPD 16(R8)(SI*8), X2
	MOVUPD 16(R9)(SI*8), X3
	MULPD  X1, X0
	MULPD  X3, X2
	ADDPD  X0, X7
	ADDPD  X2, X8

	ADDQ $4, SI   // i += 4
	SUBQ $4, DI   // n -= 4
	JGE  loop_uni // if n >= 0 goto loop_uni

    ...

end_uni:
	ADDPD    X8, X7
	MOVSD    X7, X0
	UNPCKHPD X7, X7
	ADDSD    X0, X7
	MOVSD    X7, sum+48(FP) // Return final sum.
	RET

可以看到其中使用xmm寄存器并行计算两个双精度浮点数,并且还采用了循环展开的优化手段,一个循环中同时进行4个元素的计算。

我们利用AVX2指令集并行计算四个双精度浮点数进行加速

loop_uni:
	// sum += x[i] * y[i] unrolled 8x.
	VMOVUPD 0(R8)(SI*8), Y0 // Y0 = x[i:i+4]
	VMOVUPD 0(R9)(SI*8), Y1 // Y1 = y[i:i+4]
	VMOVUPD 32(R8)(SI*8), Y2 // Y2 = x[i+4:i+8]
	VMOVUPD 32(R9)(SI*8), Y3 // Y3 = x[i+4:i+8]
	VMOVUPD 64(R8)(SI*8), Y4 // Y4 = x[i+8:i+12]
	VMOVUPD 64(R9)(SI*8), Y5 // Y5 = y[i+8:i+12]
	VMOVUPD 96(R8)(SI*8), Y6 // Y6 = x[i+12:i+16]
	VMOVUPD 96(R9)(SI*8), Y7 // Y7 = x[i+12:i+16]
	VFMADD231PD Y0, Y1, Y8 // Y8 = Y0 * Y1 + Y8
	VFMADD231PD Y2, Y3, Y9
	VFMADD231PD Y4, Y5, Y10
	VFMADD231PD Y6, Y7, Y11
	ADDQ $16, SI   // i += 16
	CMPQ DI, SI
	JG  loop_uni // if len(x) > i goto loop_uni

可以看到我们每个循环中同时用到8个ymm寄存器即一次循环计算16个数,而且还用到了VFMADD231PD指令同时进行乘法累积的计算。

最终Benchmark结果:

BenchmarkDot 一个循环中计算8个数
BenchmarkDot-2          14994770                78.85 ns/op
BenchmarkDot16 一个循环中计算16个数
BenchmarkDot16-2        22867993                53.46 ns/op
BenchmarkGonumDot Gonum点积计算
BenchmarkGonumDot-2      8264486               144.4 ns/op

可以看到点积部分我们得到了大约2.7倍的性能提升

3. L2范数计算加速

gonum库中进行L2范数计算的算法并不是常规的a1^2 + a2^2 ... + aN^2这种计算,而是采用了Netlib算法,减少了溢出和下溢,其Go源码如下:

func L2NormUnitary(x []float64) (norm float64) {
	var scale float64
	sumSquares := 1.0
	for _, v := range x {
		if v == 0 {
			continue
		}
		absxi := math.Abs(v)
		if math.IsNaN(absxi) {
			return math.NaN()
		}
		if scale < absxi {
			s := scale / absxi
			sumSquares = 1 + sumSquares*s*s
			scale = absxi
		} else {
			s := absxi / scale
			sumSquares += s * s
		}
	}
	if math.IsInf(scale, 1) {
		return math.Inf(1)
	}
	return scale * math.Sqrt(sumSquares)
}

其汇编代码比较晦涩难懂,但管中窥豹再结合Go源码可以看出来没有用到并行能力,每次循环只计算一个数

TEXT ·L2NormUnitary(SB), NOSPLIT, $0
    ...
loop:
	MOVSD   (X_)(IDX*8), ABSX // absxi = x[i]
	...

我们优化之后的核心代码如下:

loop:
	VMOVUPD 0(R8)(SI*8), Y0 // Y0 = x[i:i+4]
	VMOVUPD 32(R8)(SI*8), Y1 // Y1 = y[i+4:i+8]
	VMOVUPD 64(R8)(SI*8), Y2 // Y2 = x[i+8:i+12]
	VMOVUPD 96(R8)(SI*8), Y3 // Y3 = x[i+12:i+16]
	VMOVUPD 128(R8)(SI*8), Y4 // Y4 = x[i+16:i+20]
	VMOVUPD 160(R8)(SI*8), Y5 // Y5 = y[i+20:i+24]
	VMOVUPD 192(R8)(SI*8), Y6 // Y6 = x[i+24:i+28]
	VMOVUPD 224(R8)(SI*8), Y7 // Y7 = x[i+28:i+32]
	VFMADD231PD Y0, Y0, Y8 // Y8 = Y0 * Y0 + Y8
	VFMADD231PD Y1, Y1, Y9
	VFMADD231PD Y2, Y2, Y10
	VFMADD231PD Y3, Y3, Y11
	VFMADD231PD Y4, Y4, Y12
	VFMADD231PD Y5, Y5, Y13
	VFMADD231PD Y6, Y6, Y14
	VFMADD231PD Y7, Y7, Y15

	ADDQ $32, SI // i += 32
	CMPQ DI, SI
	JG  loop // if len(x) > i goto loop

我们采用原始的算法计算以利用到并行计算的能力,并且循环展开,一次循环中同时计算32个数,最终Benchmark结果:

BenchmarkAVX2L2Norm
BenchmarkAVX2L2Norm-2          29381442                40.99 ns/op
BenchmarkGonumL2Norm
BenchmarkGonumL2Norm-2           1822386               659.4 ns/op

可以看到得到了大约16倍的性能提升

4. 总结

通过这次优化我们在余弦相似度计算部分最终得到了(144.4 + 659.4 * 2) / (53.46 + 40.99 * 2) = 10.8倍的性能提升,效果还是非常显著的。相较于《记一次SIMD指令优化计算的失败经历》这次失败的初次尝试,本次还是非常成功的,切实感受到了SIMD的威力。

另外在本次优化过程中也涨了不少姿势

AVX-512指令降频问题

AVX-512指令因为并行度更高理论上性能也更高,但AVX-512指令会造成CPU降频,因此业界使用非常慎重,这一点可以参考字节的json解析库sonic的这个issue: https://github.com/bytedance/sonic/issues/319

循环展开优化

在一次循环中做更多的工作,优点有很多:

  • 减少循环控制的开销,循环变量的更新和条件判断次数更少,降低了分支预测失败的可能性
  • 增加指令并行性,更多的指令可以在流水线中并行执行

但一次循环使用过多的寄存器从实际Benchmark看性能确实更好,但是否存在隐患我没有看到相关的资料,希望这方面的专家可以指教一下。

...

阅读全文 »

又一个Rust练手项目-wssh(SSH over Websocket Client)

发布于
分类 Rust
标签 Rust

1. wssh

1.1 开发背景

公司内部的发布系统提供一个连接到k8s pod的web终端,可以在网页中连接到k8s pod内。实现原理大概为通过websocket协议代理了k8s pod ssh,然后在前端通过xterm.js+websocket实现了web终端的效果。

但是每次需要进pod内调试点东西都需要打开浏览器进到发布系统里一通点点点才能进入,而发布系统页面加载的又非常慢,所以效率非常低。

因此使用Rust实现了一个命令行工具,可以在本机终端中通过命令连接到k8s pod,实现了类似于ssh client的效果。这样一来不仅简化了我登陆pod的过程,又熟悉了Rust,还输出了篇博客。

项目地址:github.com/Orlion/wssh

1.2 效果

  1. 通过-e test指定为测试环境,执行后会先调用发布系统的应用列表api查询出所有应用,然后在输出中列出所有应用供用户选择 App选择

  2. 选择应用后通过连接到websocket server,websocket server转发到与pod的ssh连接,实现“SSH”到应用的pod的效果 Pod

2. 原理

公司发布系统的现状: 公司发布系统

首先我们的发布系统提供了一个Websocket Server,这个server实际代理了到k8s pod ssh连接。然后在前端通过xterm.js模拟了一个终端,通过websocket连接到server。

wssh替换了前端: 架构

3. 实现细节

3.1 命令行参数解析

wssh命令行参数解析使用了clap这个库

let clap_command = clap::Command::new("wssh")
    .version("0.1.0") // 指定版本号
    .author("Orlion") // 作者
    .about("SSH over Websocket 客户端")
    .arg(  // 添加命令行参数
        clap::Arg::new("env")
            .long("env")
            .short('e')
            .help("环境 test/preview")
            .value_name("ENV")
            .required(true),
    );
let matches = clap_command.get_matches();
// 获取--env参数值
let env = matches.get_one::<String>("env").expect("请输入--env参数");

3.2 发布系统登录

1.1节所述,wssh会调用发布系统的api,发布系统需要先登录才能调用,但是调用登录api比较麻烦,还需要用户输入账号密码,因此wssh使用了github.com/thewh1teagle/rookie 库直接读取发布系统域名下的cookie,免去了输入账号密码的麻烦,非常的简单。

let domains = vec!["jumpserver.domain.com".into()];
let cookies = rookie::chrome(Some(domains)).map_err(|e| { // 使用rookie从chrome获取jumpserver的cookie
    error::from_string(format!("获取jumpserver cookie失败: {}", e.to_string()))
})?;

let mut cookie_map: HashMap<String, Cookie> = HashMap::new();
for cookie in cookies {
    if cookie.name == "sessionid" || cookie.name == "JUMPSERVER_SESS_ID" {
        cookie_map.insert(cookie.name.clone(), cookie);
    }
}

let cookies = cookie_map
    .values()
    .map(|cookie| format!("{}={}", cookie.name, cookie.value))
    .collect::<Vec<String>>()
    .join("; ");
}

3.3 命令行中输出应用列表

在命令行中输出列表供用户选择如果手动输出的话出来的效果是比较差的,因此找到了dialoguer这个库,这个库提供了一个模糊搜索的组件FuzzySelect

let app_index =
    dialoguer::FuzzySelect::with_theme(&dialoguer::theme::ColorfulTheme::default())
        .with_prompt("请选择应用") // 提示信息
        .item("0. 退出") // 为用户提供退出的选项
        .items(&app_selections) // 输出应用列表
        .default(0) // 默认选择退出
        .interact()
        .map_err(|e| error::from_string(format!("选择应用失败: {}", e.to_string())))?;

3.4 通过websocket登陆到pod

首先使用tokio_tungstenite库建立websocket连接。

let uri = format!(
    "wss://jumpserver.domain.com/ssh?ssh_token={}",
    urlencoding::encode(ssh_token),
);
let (socket, response) = tokio_tungstenite::connect_async(uri)
    .await
    .map_err(|e| error::from_string(format!("websocket连接失败: {}", e.to_string())))?;

开发这部分连接功能时踩了个“坑”,原因是刚开始开发时对Rust的异步特性不熟悉,所以想使用同步多线程的方案,所以开始使用了tungstenite::connect()创建了同步连接,后来在进行两个线程并行读写时遇到了问题,原因是connect返回的对象的read()方法和write()方法接收的是&mut self,因为Rust不允许同时存在两个可变引用,所以并发读写是不可能的。

所以后来换成了tokio_tungstenite::connect_async()函数,这个函数返回的对象提供了split()方法可以将一个连接切分成一个读句柄和一个写句柄,这样就可以并行读写了。

另外查阅文档的过程中也得知了TCP连接可拆分而TLS连接是不可拆分的,所以如果你的websocket server可以通过ws而没有强制wss的话可以使用rs-websocket这个古老的库,这个库的同步连接方法返回的TCP连接是可以拆分的。

3.5 标准输出的调整

要在本地输出远程ssh server输出的内容之前还需要做以下三个调整。

  1. 发送window-change请求 本地终端窗口大小初始化和发生变更时都需要同步ssh server的,以便获得一致的显示效果,如果不发送可能会导致显示内容被截断或者格式不正确,并且vim等命令依赖于准确的终端尺寸来显示界面。
  2. 将标准输出设置为raw模式。在raw模式下,标准输出表现为
    • 没有行缓存,会逐字节输出
    • 不会回显输入,必须由程序写入
    • 输出未规范化(例如,\n 表示“向下一行”,而不是“换行符”)
let mut stdout = std::io::stdout().into_raw_mode()

4. 总结

通过这个项目又加深了对Rust的理解,过程中还首次用到了反人类的生命周期标注🤦🏻‍♀️(虽然后面简化掉了),收获很大,Rust远比看上去简单。

同时越发感慨Go的简易性,Go的协程结合channelselect等组件无疑极大降低了并发编程的难度,如果使用Go来开发这个工具想必难度会相当低。

我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=3ac3jhp77t0k8

...

阅读全文 »

磁盘哈希结构-Linear Hashing

发布于
分类 存储

1. Linear Hashing

最近在思考一个问题,如果一个存储引擎不需要支持范围查询,那么使用hashtable这样的数据结构是否更合适?恰好看到了lotusdb中使用了一个diskhash的库,从源码看是使用了一种Linear Hashing的哈希表数据结构,由于磁盘与内存的特性不同,因此磁盘哈希结构与常见的内存hashtable不太一样,特意研究了下。

2. 数据结构

image.png

通过primaryFile文件来存储hash桶,每个桶上有slotsPerBucket个槽,每个槽存储一个KV对,桶的数量为2 ^ Level个。当所有槽都用完时,在overflowFile中创建溢出桶,并通过nextOverflow记录新创建的溢出桶在文件中的offset。

初次创建时,会将Level初始化为0,SplitBucketIndex为0,然后创建出primaryFile并初始出一个桶

3. Put

写入过程如下:

  • 计算key的hash,然后hash对桶数量取模计算出key所在的桶
bucketIndex := keyHash & ((1 << t.meta.Level) - 1)
  • 遍历bucketIndex对应的桶以及该桶所有溢出桶上的所有slot,寻找key对应的槽
    • 如果找到则原地更新
    • 如果没有找到说明这是一个新key,则在第一个空槽上插入
    • 如果是新key且没有空槽,这时就需要创建一个溢出桶出来
  • 如果是新增key,需要判断下当前的负载因子是否超过阈值,超过阈值需要进行扩容

3.1 创建溢出桶

一般情况下溢出桶的创建是通过调用File.Truncate()扩容实现的,这里还有个逻辑是在hashtable进行扩容时,有些溢出桶不再使用可以被释放,这些溢出桶会被缓存下来,在创建溢出桶复用这些被释放的溢出桶。

4. 扩容

Linear Hashing的扩容是其核心部分,与内存hashtable常见的扩容策略有所不同,这里重点解释下

4.1 扩容时机

每当新增key之后都会重新计算当前的负载因子,负载因子的计算公式如下

keyRatio := float64(t.NumKeys) / float64(t.NumBuckets*slotsPerBucket) // slotsPerBucket是一个常量

即KV对的数量除以槽的数量。当负载因子超过阈值(默认是0.7)时触发扩容

if keyRatio > t.options.LoadFactor {
	t.split()
}

4.2 扩容过程

Linear Hashing维护一个指针SplitBucketIndex,每次扩容时就拆分这个指针指向的桶。

拆分前会将存储桶的文件扩大一个桶的大小,即增加一个桶。拆分时遍历旧桶所有槽,重新计算槽所在的位置,然后迁移到新桶上。

然后将SplitBucketIndex加1指向下一个桶,加完之后如果溢出了则将SplitBucketIndex归零,这时还要将Level+1,即标识桶数量翻倍。

5. 删除

删除过程比较简单,首先计算key的hash,然后取模计算出所在的桶,遍历桶上所有槽,如果找到槽则将槽置为空,然后写回磁盘

...

阅读全文 »

一个用rust写的类似于Skywalking/CAT的迷你trace PHP扩展

发布于
分类 Rust
标签 Rust
标签 PHP

1. 简介

最近在学习rust,恰好看到了skywalking的php扩展采用了rust编写。有用过Skywalking/CAT之类监控系统的同学应该知道,这类系统对我们开发工作帮助非常大,能够非常快的帮我们定位到问题的关键,比如说现在有一个api的请求响应非常慢,那我们就可以从系统提供的web ui中查询这个api请求的链路各个节点的耗时,从而精准的定位慢的关键。

image.png

但是这类系统搭建起来还是比较繁琐的,对于个人开发者或者一些小公司来说成本比较高,因此我在apache/skywalking-php的基础上对其进行精简和部分增强,去掉其上报到skywalking server的部分,将trace log写入到本地文件,在这个本地文件中会记录以下内容:

1. 调用CURL时,记录开始结束时间以及耗时,如果发生错误会将错误信息记录下来

{
	"trace_id": "b89143d7-0fda-43d5-a688-397aef0ee3ef",
	"kind": "CURL",
	"name": "https://error.blog.fanscore.cn/a/57/",
	"payload": {
		"http_code": "0",
		"query": "k1=v1&k2=k2&k3=v3",
		"curl_error": "Could not resolve host: error.blog.fanscore.cn"
	},
	"start_time": "10:19:03.596", // 时间格式%H:%M:%S%.3f
	"end_time": "10:19:03.602",
	"duration_in_micro": 5988 // 耗时
}

{
	"trace_id": "b89143d7-0fda-43d5-a688-397aef0ee3ef",
	"kind": "CURL",
	"name": "https://blog.fanscore.cn/a/57/",
	"payload": {
		"http_code": "200",
		"curl_error": "",
		"query": "k1=v1&k2=k2&k3=v3"
	},
	"start_time": "10:19:03.602",
	"end_time": "10:19:03.969",
	"duration_in_micro": 366647
}

2. 调用PDO函数时,记录开始结束时间以及耗时,如果发生错误会将错误信息记录下来

{
	"trace_id": "b89143d7-0fda-43d5-a688-397aef0ee3ef",
	"kind": "PDO",
	"name": "__construct",
	"payload": {
		"result": "unknown",
		"dsn": "mysql:host=127.0.0.1;dbname=blog;charset=utf8mb4"
	},
	"start_time": "10:19:03.969",
	"end_time": "10:19:03.980",
	"duration_in_micro": 11175
}
{
	"trace_id": "b89143d7-0fda-43d5-a688-397aef0ee3ef",
	"kind": "PDO",
	"name": "query",
	"payload": {
		"statement": "select * from article",
		"result": "object(PDOStatement)"
	},
	"start_time": "10:19:03.980",
	"end_time": "10:19:03.985",
	"duration_in_micro": 5471
}
{
	"trace_id": "b89143d7-0fda-43d5-a688-397aef0ee3ef",
	"kind": "PDO_STATEMENT",
	"name": "fetchAll",
	"payload": {
		"query_string": "select * from article",
		"result": "array(3)"
	},
	"start_time": "10:19:03.985",
	"end_time": "10:19:03.985",
	"duration_in_micro": 25
}

3. 捕获PHP代码中的错误

{
	"trace_id": "b89143d7-0fda-43d5-a688-397aef0ee3ef",
	"kind": "ERROR",
	"name": "E_WARNING: Undefined variable $undefined_value in /Users/orlion/workspace/nginx/www/ptrace/index.php on line 32",
	"payload": {},
	"start_time": "10:19:03.986",
	"end_time": "10:19:03.986",
	"duration_in_micro": 2
}

4. 捕获PHP代码中未捕获的异常

{
	"trace_id": "b89143d7-0fda-43d5-a688-397aef0ee3ef",
	"kind": "EXCEPTION",
	"name": "Exception: test exception in /Users/orlion/workspace/nginx/www/ptrace/index.php on line 34",
	"payload": {
		"trace": "#0 {main}"
	},
	"start_time": "10:19:03.986",
	"end_time": "10:19:03.986",
	"duration_in_micro": 1
}

5. 请求结束后会记录请求开始结束时间、状态码、GET/POST参数

{
	"trace_id": "b89143d7-0fda-43d5-a688-397aef0ee3ef",
	"kind": "URL",
	"name": "/index.php",
	"payload": {
		"$_GET": "{\"a\":\"1\",\"b\":\"2\",\"c\":\"3\"}",
		"$_POST": "[]",
		"method": "GET",
		"status_code": "200"
	},
	"start_time": "10:19:03.595",
	"end_time": "10:19:03.992",
	"duration_in_micro": 397178
}

2. 安装

  1. Requirement

很遗憾,目前只提供mac arm64版本,后续会编译出linux版本,但因为依赖的phper-framework/phper的库不支持windows,因此短期内恐怕不能提供windows版本了。

  1. 进入https://github.com/Orlion/minitrace/releases 下载编译好的扩展二进制文件到本地

  2. 假设第一步将扩展下载到了/tmp/minitrace-v0.1.0-macos-arm64.dylib,编辑php.ini配置文件加入以下配置

[minitrace]
;加载我们的扩展
extension=/tmp/minitrace-v0.1.0-macos-arm64.dylib
;将trace数据输出到/tmp/minitrace.log
minitrace.log_file = /tmp/minitrace.log
  1. 重启fpm

3. 测试使用

编辑以下php文件

<?php

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://error.blog.fanscore.cn/a/57/?k1=v1&k2=k2&k3=v3#aaa');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://blog.fanscore.cn/a/57/?k1=v1&k2=k2&k3=v3#aaa');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);

$host = '127.0.0.1';
$db   = 'blog';
$user = 'root';
$pass = '123456';
$charset = 'utf8mb4';
$dsn = "mysql:host=$host;dbname=$db;charset=$charset";
$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];
$pdo = new PDO($dsn, $user, $pass, $options);
$stm = $pdo->query('select * from article');
$rows = $stm->fetchAll();
foreach($rows as $row) {
    print_r($row);
}


var_dump($undefined_value);

throw new Exception('test exception');
?>

然后在浏览器中请求该文件,打开/tmp/minitrace.log就能看到如下输出:

{"trace_id":"b89143d7-0fda-43d5-a688-397aef0ee3ef","kind":"CURL","name":"https://error.blog.fanscore.cn/a/57/","payload":{"http_code":"0","query":"k1=v1&k2=k2&k3=v3","curl_error":"Could not resolve host: error.blog.fanscore.cn"},"start_time":"10:19:03.596","end_time":"10:19:03.602","duration_in_micro":5988}
{"trace_id":"b89143d7-0fda-43d5-a688-397aef0ee3ef","kind":"CURL","name":"https://blog.fanscore.cn/a/57/","payload":{"http_code":"200","curl_error":"","query":"k1=v1&k2=k2&k3=v3"},"start_time":"10:19:03.602","end_time":"10:19:03.969","duration_in_micro":366647}
{"trace_id":"b89143d7-0fda-43d5-a688-397aef0ee3ef","kind":"PDO","name":"__construct","payload":{"result":"unknown","dsn":"mysql:host=127.0.0.1;dbname=blog;charset=utf8mb4"},"start_time":"10:19:03.969","end_time":"10:19:03.980","duration_in_micro":11175}
{"trace_id":"b89143d7-0fda-43d5-a688-397aef0ee3ef","kind":"PDO","name":"query","payload":{"statement":"select * from article","result":"object(PDOStatement)"},"start_time":"10:19:03.980","end_time":"10:19:03.985","duration_in_micro":5471}
{"trace_id":"b89143d7-0fda-43d5-a688-397aef0ee3ef","kind":"PDO_STATEMENT","name":"fetchAll","payload":{"query_string":"select * from article","result":"array(3)"},"start_time":"10:19:03.985","end_time":"10:19:03.985","duration_in_micro":25}
{"trace_id":"b89143d7-0fda-43d5-a688-397aef0ee3ef","kind":"ERROR","name":"E_WARNING: Undefined variable $undefined_value in /Users/orlion/workspace/nginx/www/ptrace/index.php on line 32","payload":{},"start_time":"10:19:03.986","end_time":"10:19:03.986","duration_in_micro":2}
{"trace_id":"b89143d7-0fda-43d5-a688-397aef0ee3ef","kind":"EXCEPTION","name":"Exception: test exception in /Users/orlion/workspace/nginx/www/ptrace/index.php on line 34","payload":{"trace":"#0 {main}"},"start_time":"10:19:03.986","end_time":"10:19:03.986","duration_in_micro":1}
{"trace_id":"b89143d7-0fda-43d5-a688-397aef0ee3ef","kind":"URL","name":"/index.php","payload":{"$_GET":"{\"a\":\"1\",\"b\":\"2\",\"c\":\"3\"}","$_POST":"[]","method":"GET","status_code":"200"},"start_time":"10:19:03.595","end_time":"10:19:03.992","duration_in_micro":397178}

...

阅读全文 »

rust所有权和借用中的一些case

发布于
分类 Rust
标签 Rust

前言

学习rust有一段时间了,也用rust写了两个小项目,过程中发现一些rust教程在所有权和引用这一章节的讲解还是不够丰富,有很多case没有讲到,对所有权和引用的理解不够深入,这就导致实际应用时经常卡在所有权和引用,后面查阅一些资料在社区请教一些大佬后才理解,因此将最近练习过程中遇到的一些所有权和引用方面的问题总结成本文,分享给大家,帮大家踩踩坑。

1. 所有权

let a = 1;
let b = a; // a拷贝给b
println!("{}", a); // 不会报错

a的值被拷贝给了b,a和b被存储在栈上,无需在堆上分配内存

let a = String::from("a");
let b = a;
println!("{}", a); // 会报错,上一行a的所有权转移给了b,a不能再使用了

新手在这里可能会产生疑问?当执行形如let b = a;这样的代码时,到底什么情况下发生拷贝,什么情况下转移所有权呢?问题的答案其实非常简单:

只要a实现了Copy trait,那么就会拷贝,如果没有实现则转移所有权

那么为什么不能拷贝呢?我们可以以String这个类型为例,String是一个复杂类型,由存储在栈上的堆指针、字符串长度、字符串容量组成。

我们假设这里也是拷贝,那么a和b都会持有这个堆指针,当变量离开作用域后,rust会自动清理堆内存,由于a和b都指向了同一位置,那么会释放两次,这就导致了bug。

因此rust这样解决问题:当a赋值给b后,rust认为a不再有效,因此a离开作用域之后不会二次释放,这就是把所有权从a转移到了b。a被赋值给b之后就失效了,因此不能再使用。

如果String实现了Copy trait,拷贝a给b时,把堆指针指向的数据也复制一遍,同时将新的堆指针给b,那么a和b就不会指向同一个位置,就不会二次释放,自然就不会发生二次释放的bug了。

以下类型实现了Copy trait

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 true 和 false
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都是 Copy 的时候。比如,(i32, i32) 是 Copy 的,但 (i32, String) 就不是
  • 不可变引用 &T,注意: 可变引用 &mut T 是不可以 Copy的(如果Copy相当于两个指针指向一个位置,又会出现上面的二次释放的问题了)

1.1 结构体

结构体所有权问题比较复杂,这里单独拿出来分析。

先看一个简单的

struct User {
    age:
}

let user1 = User {
    age: 100,
};

let user2 = user1;
println!("{:}", user1); // 会报错,因为User没有实现Copy trait,所以user1的所有权转移给了user2
println!("{:}", user1.sign_in_count); // 会报错,user1已经无法使用了

这里要注意,虽然user1分配在栈上,但它没有实现Copy trait,仍然会发生所有权的转移

再看看一个复杂的

struct User {
    username: String,
    age: i128,
}

let user1 = User {
    username: String::from("user1"),
    age: 100,
};

let user2 = User {
    username: user1.username,
    age: user1.age
};

println!("{}", user1.age); // 不会报错,age发生了copy,而非所有权转移,可以继续使用
println!("{}", user1.username); // 会报错,username发生了所有权的转移
println!("{:}", user1); // 会报错

这里需要注意的是结构体内部的字段发生所有权转移后,会导致结构体本身也无法继续使用。但是其内部发生copy的值还是可以继续使用的,也就是user1.age还能继续使用不会报错的原因。

1.2 Option 所有权转移问题

我们先明确一个规则: 只要Option<T>中的T实现了Copy trait,那么Option<T>就实现了Copy trait

let a = Some(String::from("hello world!"));
let b = a.unwrap();
let c = a.unwrap(); // 这里会报错

我们分析下报错的原因,首先看unwrap的源码

pub const fn unwrap(self) -> T {
    match self {
        Some(val) => val,
        None => unwrap_failed(),
    }
}

从上面可以看到,调用unwrap时,因为Option<String>没有实现Copy trait,所以a发生了所有权转移,a的所有权转移到了unwrap里,所以第二次调用unwrap时就会报错。

解决办法就是调用as_ref/as_mut或者将Option<String>换成Option<&String>,rust中引用默认实现了Copy trait,所以Opiton<&String>不会发生所有权转移 看下as_ref的源码:

pub const fn as_ref(&self) -> Option<&T> {
    match *self {
        Some(ref x) => Some(x),
        None => None,
    }
}

2. 引用

2.1 可变引用

只能可变的引用一个可变变量

let a = 1;
let b = &mut a; // 会报错,无法可变引用一个不可变变量

同一时刻只能存在一个可变引用

let mut a = 1;
let b = &mut a;
*b = 2;
println!("{}", a); // 会报错,可以将a理解成1的一个引用,因为下一行println!("{}", b);所以b这个可变引用的生命周期还未结束,那么此时如果使用a,则违反了可变引用与不可变引用不能同时存在的规则
println!("{}", b);

2.2 解引用

结构体解引用

let user = String::from("user");
let user_ref = &user;
let _user_1 = *user_ref; // 报错

第三行会报错:

error[E0507]: cannot move out of `*user_ref` which is behind a shared reference
  --> src/main.rs:30:19
   |
30 |     let _user_1 = *user_ref;
   |                   ^^^^^^^^^ move occurs because `*user_ref` has type `String`, which does not implement the `Copy` trait

这个报错看到有解释说不能解引用获取到所有权(String没有实现Copy trait只能将user的所有权转移给_user_1),但是这里将user的所有权转移给_user_1也并不会造成什么错误,所以我猜测是rust编译器限制了不能通过解引用间接转移所有权,只能直接转移。

这里还有个case:let _user_1 = &(*user_ref); 这种写法可以编译通过,猜测是编译器优化直接拷贝的引用,而不是先转移所有权再取引用。

3. 参考资料

...

阅读全文 »