​ 一直感觉对kubernetes中的qos是一个盲点, 借着复习下scheduler的一些资源调度策略来学习下k8s中的qos策略, 然后再辐射到一般性的qos策略.

动机:

k8s 用非常简单的方式分配资源. 用户能够指定容器的资源限制. 比如一个用户能够指定某个container 只能使用1GB的内存. scheduler通过资源限制去调度容器. 如果某个节点的内存只有4GB, 那个一个有5Gb请求的容器就将不能调度到这个节点. 目前, k8s不能保证容器在一个超卖的系统中运行稳定.

目前的实现中, 如果用户指定limts给所有的container, 那么集群资源的利用率将会非常低下. 因为容器往往无法充分使用用户指定的那些资源. 一个可能的方式是不指定limits, 这样container就可以无限制的使用, 但是如果这些container无限制的使用资源, 就可能使得指定了limits的容器由于机器资源不够而被杀掉. 这是用户不愿意看到的, 他们希望自己指定了某个大小, 那么启动之后系统就应该保证这个容器的顺利运行.

qos机制就是在节点资源超卖的环境下, 通过提供不同级别的保证来满足资源的需求. 容器可以用request请求一个最小资源, request与limit不同, container可以使用超过request的值. best-effort级别的container相当于request为0的container. Best-effort container只使用那些其他container没有使用的资源, 可以用于资源清理(这个没看懂).

Request and limits

对于每种资源, container可以指定request和limits, 0 <= request <= limit <= infinity. 如果container成功调度, 意味着container能够保证至少有request的值的资源. container不能超过limit的值. 而request 和 limit 如何执行要看资源是compressible还是incompressible.

Compressible Resource Guarantees

  • 目前只有cpu
  • container至少得到request的cpu请求
  • 剩余的cpu会按照cpu request比例分配给container. 比如container A request 60%, container B request 30%. 假设两个container都尝试拿到更多的cpu, 那么剩余的10%将按照2:1的比例分配.
  • 如果超过limits, Containers 会被节流(不是被杀死).

Incompressible Resouce Guarantees

  • 目前只有内存. (我认为本地存储也在这一类)
  • Containers能够得到request的大小, 如果超过这个大小, 它们可能会被杀掉(如果其他container需要内存), 但是如果containers消耗的少于request值, 他们不会被删除(除非系统任务或者daemonset需要更多的内存)
  • Containers会被杀掉如果他们使用了超过limit的内存.

Kubelet admission 策略

kubelet通过统计containers的request来确保系统资源不会被超载.

QoS 分级

概述

k8s Qos 分成三个等级:

  • Guaranteed
  • Burstable
  • BestEffort

理论上QoS与limit, request应该是互不干扰的, 但实际上他们的联系非常紧密. QoS class不是自己设置的, 而是在创建pod的时候, 根据limit和request系统自动确认的.

Guaranteed

这是级别最高的, 他的触发条件是:

  • 每个pod中container必须有内存的limit和request, 而且必须相同
  • 每个pod中cpu必须有内存的limit和request, 而且必须相同

效果: 这些containers是最高优先级

注意: 如果设置了limit没有设置request, 系统将会自动填充request跟limit相同.

####Burstable

这个的触发条件是:

  • pod不满足Guaranteed
  • pod中至少一个container有内存或者cpu的请求.

效果: 能够保证request的请求, 但是不保证limit, 如果超出request请求的内存大小, 发生oom时可能会被杀死.

BestEffort

触发条件: 很明显, 没有指定任何request和limit或者值都是0就是BestEffort级别.

效果: 这些containers没有请求资源保障, 会被认为是最低优先级的, 如果系统发生oom, 他们会被首先杀死. 他们只会使用集群中没有被使用的那部分资源.

总结

所以k8s的qos主要是通过两方面: request,limit 和 qos class来实现的. 这两方面又有很多交叉的地方. 而且在面对不同的资源的时候, 他们的策略是不一样的.

Storage QoS

由于k8s中没有关于存储的qos机制, 于是关于存储的qos, 查看了一些文档.

最近在调查一个kubernetes中发现Kubelet的pods目录:

1
/var/lib/kubelet/pods/xxx/volumes/

下出现了大量的包含”deleting~” 的目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~859156558
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~912994645
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~096627888
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~361944655
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~827756898
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~850958169
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~435144420
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~573873907
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~817019830
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~300298653
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~414447192
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~453118423
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~634999626
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~329196065
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~705907980
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~060876539
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~371568670
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~473777381
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~852926720
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~911951455
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~221614642
/var/lib/kubelet/pods/612f2e76-3ce1-11e8-b2c9-0cc47ae2b22c/volumes/transwarp.io~tosdisk/pvc-f39e86b9-019b-11e8-b2c9-0cc47ae2b22c.deleting~643761641

导致每次reconciler将这些多余的”deleting~”加入到”ActualOfWorld”中, 然后触发大量的Umount操作, 使得reconciler很久才Loop一次, 现象就是pod create和delete都变得非常得慢.
一. 一开始, 我发现自己写的plugin中使用了pkg/volume/volume.go中的RenameDirectory函数, 函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func RenameDirectory(oldPath, newName string) (string, error) {
newPath, err := ioutil.TempDir(filepath.Dir(oldPath), newName)
if err != nil {
return "", err
}

// os.Rename call fails on windows (https://github.com/golang/go/issues/14527)
// Replacing with copyFolder to the newPath and deleting the oldPath directory
if runtime.GOOS == "windows" {
err = copyFolder(oldPath, newPath)
if err != nil {
glog.Errorf("Error copying folder from: %s to: %s with error: %v", oldPath, newPath, err)
return "", err
}
os.RemoveAll(oldPath)
return newPath, nil
}

err = os.Rename(oldPath, newPath)
if err != nil {
return "", err
}
return newPath, nil
}

在每次删除目录时, 并不是直接删除, 而是先创建一个随机的空目录, 然后将原目录rename到随机目录, 最后再将这个随机目录删除掉.
看似没有什么问题, 但不巧的是, 在golang1.8之后, os.Rename的实现发生了变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func rename(oldname, newname string) error {
fi, err := Lstat(newname)
if err == nil && fi.IsDir() {
// There are two independent errors this function can return:
// one for a bad oldname, and one for a bad newname.
// At this point we've determined the newname is bad.
// But just in case oldname is also bad, prioritize returning
// the oldname error because that's what we did historically.
if _, err := Lstat(oldname); err != nil {
if pe, ok := err.(*PathError); ok {
err = pe.Err
}
return &LinkError{"rename", oldname, newname, err}
}
return &LinkError{"rename", oldname, newname, syscall.EEXIST}
}
//------------------------版本分割线1.8
err = syscall.Rename(oldname, newname)
if err != nil {
return &LinkError{"rename", oldname, newname, err}
}
return nil
}

在虚线以上是go1.8之后新加的内容, 如果rename之后的目录存在, 就会打印”File Exits”错误, 这样就会创建大量的”deleting~”目录. 相关修改和讨论在Bugs in EmptyDir Teardown path.

以为问题就这样解决了, 然而不是, 我检查了版本, 使用的是1.7.4, 同时查看了日志, 并没有打印”FILE Exist”的log, 而是打印了”device or resource busy”. 通过多种测试, 发现rename操作无论是进程占用还是进程对文件的读写, 都不会导致device busy问题. 这说明我找到了出问题的地方, 却没找到背后的原因.

二. 如果rename没有返回错误, 那么只能是之后的remove操作返回的错误. 然而却有几个疑点:

  1. 查看rename出来的deleting目录中都是空目录, 里面并没有数据
  2. 手动可以remove掉这些目录
  3. 原目录并没有消失, 而是存在且有大量的正在更新的数据.

​ 显然问题比我想象的更加复杂. 有一个可能的解释是在我操作delete之后由于太长时间没有删掉导致Kubelet直接暴力删除了这个pod, statefulset又重启了新的pod, 原来的container虽然删掉但相应的namespaces中依然有应用程序在读写, 这种情况导致remove busy, 而且数据在更新.

这些问题要调查起来都很费力. 显然直接删掉rename逻辑可以解决这个问题, 于是不再追究.

星环讲座: 初识微服务

主讲人: 吴伟

三本书:

  • Building Microservices
  • 微服务: 从设计到部署
  • 微服务设计

大纲:

  1. 什么是微服务
  2. 核心概念
  3. 开源微服务框架
  4. 国内微服务产品

内容:

  1. 什么是微服务: 相互独立微小的组建, 能够相互合作: 在一组小的服务, 独立的进程, 通信机制, 基于业务, 独立部署, 没有集中式管理.
  2. 为什么要做微服务: 单体服务膨胀太大, 代码太多, 开发太慢, 发布速度太慢, 新人学习成本太高.
  3. 微服务目标: 敏捷迭代, 灵活扩展, 服务服用.
  4. 微服务核心概念: api网关, 服务发现, 熔断, 限流, 降级, 配置中心, 自动化部署和测试, 日志监控和分部署追踪, 安全, 服务拆分, 服务接口定义, 有状态服务集群, 无状态服务
    • 服务发现: 怎么找到另一个服务的地址. 通常使用服务中心或者服务代理.
    • api网关: 对外统一接入访问; 内外协议转换(http -> gRPC), 统一认证,监控,负载均衡,缓存; 智能路由: Kong, Nginx Plus, Traefik
    • 服务容错(熔断): 假设错误一定会发生, 想办法把损失降到最小: custom fallback, fail silent, fail fast. 超时与重试, 降级熔断, 连接隔离
    • 配置中心: 程序运行时动态调整行为的能力.
    • 自动化测试部署: CI/CD 改动一行代码能够多久部署成功
    • 服务监控: 系统在做什么, 哪些组建流量比较大, 这个请求在哪个地方失败了, 哪个调用比较慢.
    • 安全:
    • 分布式事务: 每个服务解决一个问题, 要解决一个逻辑上的一致性.

cloud native = container + CICD/devops + microservice

在linux的权限中, 发现不仅仅有rwx, 还有一个t, 如下所示:

1
drwxrwxr-t   2 shentao shentao       4096  8月 24  2016 test/

t只能加在最后一个组, 也就是其他组的权限中, 他的意思是sticky bit, 表示其他组成员不能对该文件进行删除和重命名操作, 这是为了防止某个文件无意被其他组的成员删除和重命名后导致owner找不到文件.

添加的方式是:

1
chmod o+t test

或者

1
chmod +t test

或者使用数字的方式, 在首位设置1

1
chmod 1775 test

结果如下:

1
2
ll test
drwxrwxr-t 2 shentao shentao 4096 8月 24 2016 test/

移除方式如下

1
chmod o-t test

查看结果

1
2
3
ll test

drwxrwxr-x 2 shentao shentao 4096 8月 24 2016 test/

从这里也能看出, t是比x更高一个级别的权限, 即t包含了可执行权限, 只是不能delete和rename. 其次它与rw也不冲突.

问题描述

今天在执行一个命令是遇到一个which路径与实际执行不同步的问题, 如下:

1
2
3
4
5
6
7
8
9
shentao@shentao-ThinkPad-T450:~/blog$ which ss-local
/usr/local/bin/ss-local
shentao@shentao-ThinkPad-T450:~/blog$ /usr/local/bin/ss-local -v | grep shadowsocks-libev
shadowsocks-libev 2.5.6 with OpenSSL 1.0.1f 6 Jan 2014
shentao@shentao-ThinkPad-T450:~/blog$ ss-local -v | grep shadowsocks-libev
shadowsocks-libev 3.1.3
shentao@shentao-ThinkPad-T450:~/blog$ /usr/bin/ss-local -v | grep shadowsocks-libev
shadowsocks-libev 3.1.3

which 现实路径是/usr/local/bin/ss-local, 但实际执行的路径却是/usr/bin/ss-local. 查看which的文档, 发现which是按顺序搜索PATH环境变量来查找的:

1
2
shentao@shentao-ThinkPad-T450:~/blog$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/usr/local/go/bin:/usr/local/go/bin:/home/shentao/gopath//bin:/opt/idea-IC-145.1617.8/bin:/usr/local/go/bin:/home/shentao/gopath//bin:/opt/idea-IC-145.1617.8/bin:/usr/local/go/bin

所以最先在/usr/local/bin中找到.

问题解决

于是去调查了下bash的执行过程和顺序. 得到了答案. https://crashingdaily.wordpress.com/2008/04/21/hashing-the-executables-a-look-at-hash-and-type/

原来在每一次执行过程中shell都会有一个hash table作为cache将调用过的可执行命令存入hash table, 这样再下次调用的时候就可以直接从缓存中读取, 而不用每次去搜索PATH. 我们通过type命令可以查看:

1
2
shentao@shentao-ThinkPad-T450:~/blog$ type ss-local
ss-local is hashed (/usr/bin/ss-local)

可以通过hash命令删除:

1
2
3
shentao@shentao-ThinkPad-T450:~/blog$ hash -d ss-local
shentao@shentao-ThinkPad-T450:~/blog$ ss-local -v | grep shadowsocks-libev
shadowsocks-libev 2.5.6 with OpenSSL 1.0.1f 6 Jan 2014

知识回顾

我们再来回顾下关于bash 的一些知识:

关于bash可以执行的类型

  • Aliases: An alias is a word that is mapped to a certain string, 命令的别名.

    1
    2
    $ alias nmapp="nmap -Pn -A --osscan-limit"
    $ nmapp 192.168.0.1
  • Functions: A function contains shell commands, and acts very much like a small script. 用function定义一个函数:

    1
    function gcc { echo “just a test for gcc”; }
  • Builtins: 内置函数, cd之类的

  • Keywords: Keywords are like builtins, with the main difference being that special parsing rules apply to them. For example, [ is a Bash builtin, while [[ is a Bash keyword. They are both used for testing stuff. [[ is a keyword rather than a builtin and is therefore able to offer an extended test:

    1
    2
    3
    $ [ a < b ]
    -bash: b: No such file or directory
    $ [[ a < b ]]

    第一个< 是重定向, 第二个加了[[的关键词之后 < 成了小于号.

  • Executables: (Executables may also be called external commands or applications.) Executables are commonly invoked by typing only their name. This can be done because a pre-defined variable makes known to Bash a list of common, executable, file paths. This variable is called PATH. 外部命令, 也就是PATH中找到的那些命令

  • Script: 脚本. 比如test.sh

关于bash的搜索顺序总结

bash搜索的顺序是: 当前路径和绝对路径的目录->alias->keyword->function->built-in->Executables, Script(hash)->Executables, Script($PATH)

相关命令

bash, hash, type, which, alias, function

Istio: 一个开源平台去链接, 管理和保障微服务: 分布式微服务代理。

传统代理: 类似于一个线, 任何服务都经过这个代理。

分布式代理: 代理运行在每个应用上,接管应用进出的流量并和统一的控制中心通信获取动态配置。

微服务代理: service mesh: 互连的分布式代理。

Istio:

  1. Pilot: 基于http做流量的;

  2. Mixer: 做数据统计, 授权认证

  3. CA-Auth: 做安全认证

Envoy: 微服务代理。

苗艳强,闫学安

ZTE 工程师,主要从事容器相关开源工作,Kubernetes 社区 member, containerd 社区 member

课程详情:

演讲主题:

目前跟 Kubernetes 对接的默认容器运行时是 docker,然而 docker 从 1.12 版本开始加入了 swarm 功能,随着版本的演进,功能越来越庞大,已经成为一个跟 Kubernetes 同级甚至高一级的编排工具,且前不久又宣布在编排测要无缝对接 Kubernetes,因此,docker 作为 Kubernetes 默认容器运行时的位子必将被其他工具代替。本次分享首先简单介绍 Kubernetes 的运行时接口规范,然后着重为大家分享一款新的容器运行时工具 containerd,包括项目介绍,功能组成等,以及使其原生对接 Kubernetes 的插件 cri-containerd。

纲要/提纲:

  1. Kubernetes 容器容器运行时接口(CRI)

  2. containerd 项目介绍

  3. containerd 的 CRI 实现


Containerd的CRI实现
kubelet -> cri-containerd -> containerd

在hexo中, 在themes目录下git clone了一个子目录themes/next. git push 到远程仓库后没有看到themes/next中的内容, 也无法git add

1
2
3
4
5
6
shentao@shentao-ThinkPad-T450:~/blog$ git commit -m "fix" themes/next/
On branch master
Changes not staged for commit:
modified: themes/next (modified content)
shentao@shentao-ThinkPad-T450:~/blog$ git add themes/next/*
fatal: Pathspec 'themes/next/bower.json' is in submodule 'themes/next'

当创建一个子git项目是, 项目就叫做git submodule结构, 外部无法对子模块进行控制, 有两种方式(我知道的):
1.

1
2
3
cd themes/next
git add .
git commit

再到外部提交

1
2
3
git add themes/next
git commit
git push
  1. 1
    2
    git rm --cached themes/next  
    git add themes/next

写了一个简单的程序, 想通过外部环境变量传递给程序的全局变量, 这个全局变量又被另一个全局变量使用, 发现另一个变量无法改变, 然后发现一些有意思的golang程序初始化顺序, 需要稍微注意一下.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"fmt"
"flag"
)

var (
// 1
size = flag.String("size", "3", "size")
a = "1"
b = a + "2"
c = b + *size
)

func init() {
// 2
fmt.Printf("%s, %s, %s, %s \n", a, b, c, *size)
a = "2"
b = a + "2"
}

func main() {
fmt.Printf("%s, %s, %s, %s \n", a, b, c, *size)
// 3
flag.Parse()
fmt.Printf("%s, %s, %s, %s \n", a, b, c, *size)
}

输出如下:

1
2
3
4
/home/shentao/.IdeaIC2016.1/config/plugins/Go/lib/dlv/linux/dlv --listen=localhost:34206 --headless=true exec "/tmp/Build initializetest.go and rungo" -- -size=10
1, 12, 123, 3
2, 22, 123, 3
2, 22, 123, 10

注释标记的是实际运行顺序, 先是变量的初始化, 此时size还没传入解析, 所以使用默认值”3”; 其次是调用init函数; 最后是main函数, main函数再进行flag.Parse(), 此时size改变, 但是其他变量不会再变.

本书评不涉及剧透,请大胆的往下看。
​ 阿加莎的书的特点是将你往某种定势思维上引,然后打破这种定势思维。《无人生还》亦是如此。那么如何排解定势思维呢?这本书在侦破的过程中提供了两个思维。

​ 第一个是排除。即一开始不去想最可能的结果,而是将所有已知的证据放在桌面,然后不断排除所有不可能的结果,最后得到的结果无论多么离谱,都是在已有证据下的唯一结果。

​ 第二个思维是回溯。当我们得到一个唯一结果,或者得不到唯一结果,也就是所有结果都被排除掉,那么只有一种可能就是证据本身是错的,这就是有证人在说谎,或者有人伪造了证据,这时候需要回溯到证据提出的过程中,提供假证据必然要掩盖某个事实,从某个最容易甄别的假证据出发找到一个真事实,通过这个真事实,按照第一个思维,可以继续推倒出其他与之相违背的证据,从而重新得到证据链。

​ 我不禁有一个有意思的理论和想法:如果每个人做的事情都看做一条平面上的直线,那么我们肉眼所能看到的案发现场,就是这所有直线的有且仅有的一个交点。有任何一个人在任何时候说谎,那条直线就会发生偏折,就无法相交于一点,此时为了相交于一点,有两种方法:说更多的慌让直线折回来;让其他人也说谎,找一个新的交点。本书的犯人无疑是后者。但想找一个新的交点何其难,终究被识破。

​ 其实还有第三种方法。昨晚看了《黑镜》最新一季的某一集,女主角与男友因为酒驾撞死了一个男子,然后抛尸。时隔多年男友良心不安想要去告诉被害者家属,女主角将他杀害,而杀害的过程被正在调查车祸的女调查员发现,于是女主角杀害了女调查员,并得知她的家庭,于是杀害了她的所有家人。所以这个方法就是把平面上所有其他直线擦掉,平面上只有一条直线,和一个看起来不相交的点。

0%