普通视图

发现新文章,点击刷新页面。
昨天以前Lss233's.Blog();

10 分钟快速入门垃圾回收机制

作者 Lss233
2023年1月31日 21:55
10 分钟快速入门垃圾回收机制

自动内存管理是编程语言发展历程上的一项伟大发明。

在没有自动内存管理前,人们都是手动进行内存管理。在 C 语言中,我们申请内存时,会使用 malloc 函数向操作系统申请内存空间,使用结束后,我们使用 free 函数释放内存。

于是,一些写了 C 语言比较久的同学可能会发现,自己的程序经常遇到内存泄漏、double freeuse after free 等错误,这都是因为我们在管理这些内存时,没有正确的释放他们导致的。

而带有自动内存管理的编程语言却不一样,它们带来了一个叫做 runtime 的东西,像操作系统一样,帮我们为新对象分配空间、回收不需要的内存空间,为我们减轻了许多负担。

这篇文章将介绍垃圾回收机制的常用策略。我们将不会讨论特定编程语言是怎么实现的,而是只介绍一些通用的方法,这样你在看任何有关垃圾回收的文章时都可以很轻松地去理解它的原理。

清理垃圾这件事其实只有两步:

  1. 找垃圾
  2. 丢垃圾

0x01 谁是垃圾?

垃圾的定义就是:没人用的东西。

在发现垃圾这件事上,我们的重点在于怎样确定哪些对象是没用的。

为此,我们有两种常见的方法:

1. Reference Counting GC - 引用计数算法

每个对象都有一个与之关联的引用数目 refCount, 多一个指针引用就 +1,少一个指针引用就 -1

当一个对象 refCount <= 0 的时候,它就是个没人要的垃圾。

10 分钟快速入门垃圾回收机制

优点一:实现简单
实现的角度来看,这种垃圾识别的算法实现的过程与 runtime 耦合较小,只要我们能够在一个变量被赋值的时候判断一下,就可以实现 refCount 的更新,所以它简单到可以作为一个单独的库来实现。

C++ 中的智能指针 shared_ptr 就采用了这种引用计数的方法,它重写了 = 操作符,让它拥有了这一能力。

优点二:不需要独立的寻找过程
执行的角度来看,垃圾的寻找直接绑定在了程序执行过程中。

我们不需要专门地去写一个复杂的体系来替我们发现垃圾,代码在运行的时候自己就能替我们标记出谁是垃圾。

但问题也来了:

缺点一:维护 refCount 的开销其实很大

如果有多个线程都想引用对象 A,那么对象 A 的 refCount 就会被多个线程同时修改。如果一个线程在修改 refCount 时被其他线程打断,那么 refCount 的值就会变得混乱,从而导致并发安全问题

学过操作系统的同学应该会熟悉:为了避免并发问题,我们需要保证refCount 具有原子性

保证原子性的手段就是在修改refCount前对它加锁。只要加锁,其他想修改refCount的线程就会停下来等待锁被释放。

但这便带来了另外一个问题:当我们在多线程的环境里引用公共对象时,多线程的效率会严重降低,这是不可忍受的。

缺点二:无法回收循环引用
如果大家写过环形队列,肯定还记得它的核心特点:把头部和尾部连接,形成一个循环引用。

这种操作看似巧妙,但对我们的算法而言将是一个巨大的打击:

假如你不想要这个环形队列了,但它们的 refCount 都是 1,引用计数算法无法认为这是一坨垃圾,它会一直留在内存中,造成内存泄漏

10 分钟快速入门垃圾回收机制

2. Tracing GC - 追踪垃圾回收算法

对于引用计数算法这一问题的本质其实还是不能鉴别出谁到底有没有用。

如果说我们从头开始遍历所有的对象,看他们现在在用谁,这样不就知道谁是垃圾了嘛!

于是我们便有了 Tracing GC ,它也被称为可达性收集法

其基本原理就和深度优先搜索一样:

  1. 标记根对象(如常量、栈空间中的对象等)
  2. 从根对象出发,扫描所有的可达对象
  3. 那些不可达的,就都是垃圾了

好吧,这么一看我们是从根源上解决了最大的问题。

但是别急,事情还没这么快结束。

解决一个严重 BUG 的方法就是引入 N 个新的 BUG,接下来我会向你证明这一点。

让我们看看 Tracing GC 在其他方面表现得怎么样:

  1. ✔ 开销 —— 并发安全:因为我们没有原子性的变量要去维护了,所以现在我们可以想怎么写就怎么写。
  2. ❌ 实现 —— 与 runtime 高度耦合: 与引用计数不同,根对象的识别在不同的编程语言都不太一样,因此不同的 runtime 实现都不太一样。
  3. ❌ 执行 —— 复杂的寻找过程:如何在程序运行的同时扫描垃圾? Tracing GC 的最大挑战就在这里。

复杂的寻找过程

Tracing GC 是通过扫描的方式来识别垃圾的,扫描工作会被安排在一种叫做 GC Thread 的线程中进行。

扫描的过程有三种:

  • Serial GC: 只有一个 GC Thread 干活
  • Parallel GC: 有多个 GC Thread 同时干活
  • Concurrent GC: 业务线程和 GC 线程同时执行

Serial GC 和 Parallel GC 的区别只有线程的个数,它们和业务线程的关系是同步执行

注意:这里所说的同步执行不是指一起执行,而是一个执行完才能执行下一个。

10 分钟快速入门垃圾回收机制

在 GC 执行期间,所有的业务线程都会被暂停,整个程序看起来会像卡死一样,这被称为 Stop The World

这听起来太糟糕了,所以我们迫切希望 GC 能够并发执行

并发 GC 的改造:三色标记法

并发意味着我们的 GC 扫描的过程中,CPU 会在我们的业务代码和 GC 的扫描代码之间不断地进行上下文切换 —— 以保证业务和 GC 都能不断运行。

此外,我们也不希望 GC 一扫就扫半天,我们希望能有多个 GC 线程帮我们一起扫描。

这么一来,我们需要三种不同的颜色来表示对象,代表三种不同的状态:

  • 白色:对象从被未访问过
  • 灰色:扫描过对象本身,正在扫描它的全部引用的对象
  • 黑色:扫描过对象本身,也扫描过它全部引用的对象

我们用三个不同的集合来放这些对象。

在最开始时,所有的对象都在白色集合中。

扫描时,我们还是从根节点开始,先将根对象标为黑色、被根节点引用的对象标为灰色。

接下来,我们的 GC 线程只要不断地从灰色集合里拿对象,扫描之后丢进黑色对象就好了。

当灰色集合为空时,扫描完毕。此时白色集合中的对象就是垃圾,可以被直接清除。

这种改造其实就是把深度优先搜索改造成了广度优先搜索,从而实现了并发。

并发陷阱:误删

我们来看如果业务线程和 GC 线程同时执行会怎么样:

10 分钟快速入门垃圾回收机制

上图表示 GC 运行时的 3 个时刻。

  • 时刻 1:我们的 GC 从对象 A 扫描出了对象 B 和 对象 C
  • 时刻 2:对象 A 增加了对象 D 的引用
  • 时刻 3:对象 B 删除了对象 D 的引用

因为我们只扫描灰色的对象,对象 D 从事实上来说并不是垃圾,但它完美地错过 GC 了,将会遭到误删,是个大问题。

要想解决它,我们就得通过某种操作,让我们的 GC 可以记录下这个白色对象 D。

我们可以从两个不同的角度来分析它:

  1. 在时刻2:黑色对象增加了一个白色对象的引用
    如果想从这个角度解决问题,那就在黑色对象增加引用时,把这个对象 D 记下来,待会扫描它,这被称为增量更新

  2. 在时刻3:灰色对象断开了一个白色对象的引用
    如果从这个角度解决问题,那就在灰色对象断开引用时,把对象 D 记下来,待会扫描它,这被称为原始快照。它意味着无论对象怎么改变,我们还是按照刚开始 GC 时的对象状态来扫描它。

时间停止:STW

现在,我们有了一个看似没有 BUG 的并发GC,它分为三个阶段:

  • 初始化阶段:找到所有的根对象
  • 并发标记阶段:三色标记法扫描
  • 重新标记阶段:扫描并发标记阶段错过的白色对象

但我们在执行扫描时,还是会遇到 Stop-The-World

我们的 Stop-The-World 将会发生在扫描的在第一阶段和第三阶段。

  • 第一阶段:停止根对象的制造,以确定扫描起点。

  • 第三阶段:停止新对象的制造,以保证没有新对象错过 GC。

不过还好,因为根对象和重新扫描的对象数量其实不多,所以这两个 Stop-The-World 的间隔将会很短

10 分钟快速入门垃圾回收机制

0x02 丢垃圾

通常来说,清理垃圾有三种方法,他们都很好理解。

Mark-sweep GC: 将垃圾对象的内存标记为可分配

这种清理垃圾的方法应该是最快的,因为它其实根本没有清理,只是单纯地声明这个地方可以放新对象了。

10 分钟快速入门垃圾回收机制

但清理的时候轻松了,要用的时候就没那么容易了。

我们都知道,对于新来的对象,如果想要找个安家的位置,那必须得挑选一个合适自己大小的空闲空间。

不同的对象变成垃圾的时间肯定不一样,这一通操作清理完以后,会把可用空间变得东一块西一块,甚至有可能出现一种极端的情况:就算总体的可用空间满足对象的需求,但因为被分散到各个角落,所以无法分配。

这种情况我们叫做内存碎片化

Compact GC: 移动并整理存活对象

和我们平时打扫卫生一样,我们在丢垃圾的时候,也会顺手把房间整理一下。

要解决碎片化的问题,要实现的就是把存活的对象放到内存中的一端。

这样,另外一端就全部都是垃圾,可以用来分配新的对象。

10 分钟快速入门垃圾回收机制

这种操作让我们的可用内存和空闲内存都是连续的,使得我们在分配新对象时可以进行指针碰撞

指针碰撞指的是:如果我们把内存当作一个线性表,用一个指针cur指向线性表中第一个可用空间的地址,

那么如果要分配一个新的size的对象,就可以直接得知它的起始位置是 cur,分配完完这个对象以后,下一个可用的位置 cur = cur + size

// 为一个新对象申请空间
// @param size 新对象的大小
// @return 新对象的起始地址
(void*) allocate(int size)
{
    if(cur + size <= total_size) { // 判断空间是否足够申请
        int addr = cur;	// 新对象的起始地址
        cur = cur + size; // 下一个对象的起始地址
        return addr;
    }
    return nullptr; // 空间不足,申请失败
}

但这样做其实还有一个问题:整理的过程需要时间。

其实不难理解,扫描完一次垃圾之后的内存空间肯定是东一块西一块,如果想让所有可用的对象乖乖地贴在一端,那肯定得交换。

Copying GC: 将存活对象复制到另外的内存空间

和上一种算法相比,它就简单粗暴的多了。

这种算法在清理垃圾的时候,直接找一块干净的内存空间(现在没有存对象的),然后把前面标记的不是垃圾的对象全部丢进去。

这样,原来的那块空间就全部都是垃圾了。

10 分钟快速入门垃圾回收机制

虽然还是需要复制,但至少它整理的过程少了交换这一步骤。

但这种丢法也有它的缺点,比如说:必须找一块干净的内存空间,奢侈。

如果存活的对象比较多的话,就得复制一堆东西,花时间。

既然这些丢垃圾的方法各有不同的缺点,那么我们在实际中肯定要根据对象的特点来选择合适的清理策略。

混合清理策略 —— 分代假说

这个算法很多编程语言的 runtime 都在用,特别是 Java。

分代假说认为:大部分对象都属于早年夭折的类型。

这也很好理解,毕竟我们现在的编程语言都喜欢认为万物皆对象,你的一个函数里可能就造出了好几个对象,但最终只返回那么一两个。

所以,如果我们为每个对象设置一个“年龄” —— 该对象所经历过的 GC 次数。

这样一来,我们就可以区分出对年轻的对象和老的对象,并为他们分别采用不同的 GC 策略。

  • 年轻代: 由于存活对象少,可以采用 Copying GC 的策略来清理。

  • 老年代: 由于一直活着,反复复制开销较大,那我们就采用 Mark-sweep 的策略清理。

0x03 编程语言运行时中的垃圾收集器

上面我们说的那些都是理论基础,如果我们从里面挑选一两个进行实现,那就成为了编程语言 runtime 中的 垃圾收集器。

在实现算法的过程中,垃圾收集器会根据编程语言的特性来修改算法,

以 Java 为例, 从 JDK1.1 到 JDK1.8,Java 的开发者们开发出了 6 种 GC 收集器,都是以跟踪标记的方式寻找垃圾,使用分代假说清理垃圾。随着 JDK1.8 发布的 G1 收集器会还通过建立数学模型来预测 Stop-The-World 的时间,让停顿更加可控,被广泛应用于商业领域。

PHP 则是知名的引用算法使用者,这和 PHP 主要使用的 CGI 模式有关,PHP 代码的生命周期主要在一次请求结束,所以它很少有机会开启一个新的进程来清理垃圾。

Python 为了让自己可以适应各种应用场景,直接开放了 gc 模块,允许开发者实现自己的垃圾清理算法。

Go 通过更为先进的内存分配算法 TCMalloc 解决了内存碎片化的问题,通过编译器优化实现逃逸分析,让早期创建的对象直接在栈上销毁,从而不依赖分类假说,让 STW 停顿缩短至微秒级别。这是它迅速走红的原因之一。

本文介绍的垃圾收集算法并不完善,在学习具体的垃圾收集器算法时,可以结合这门编程语言的特征和它的使用场景,再来分析它是如何修改和完善这些算法的,这样可以学习得更快。

参考链接:

  1. JVM GC 동작 순서
  2. Tracing Garbage Collection
  3. Java基础:JVM垃圾回收算法
  4. 垃圾回收(GC)算法介绍(2)——GC引用计数算法
  5. TCMalloc Overview

MiniDB 开发手札2 - 网络通信: PostgreSQL 服务端实现

作者 Lss233
2022年10月3日 21:46

要写一个能够进行网络通信的协议,我们需要有客户端和服务端,定义各种数据包格式以及它们的交互流程,然后需要考虑安全性、效率等各种因素……实在是太麻烦了!所以,与其从头开始设计一个通讯协议,为什么不先研究一下现有数据库系统的协议呢?

如果我们直接实现了某个数据库的协议,那么这款数据库的客户端就可以直接连接到我们的数据库上,这样的话,我们岂不是连客户端都可以不用写了。嘿嘿……

抱着这种想法,本人在网上进行了一番调研,最后决定使用 PostgreSQL 数据库的通信协议。

选择 PostgreSQL 主要是因为……

  1. 社区活跃,资料丰富(不知道的能 Google)
  2. 官网有较为详细的通信协议文档(我看得懂)
  3. 版权属于社区,不会因为某些商业决定波及到自己(然后你就白写了)
  4. 开源协议自由,允许基于它修改的代码商用(万一哪天我的项目成名了呢?)

还有一件值得一提的事情是,GitHub 上使用 Kotlin/Java 和 PostgreSQL 协议实现的项目不多,就算有,也都是作为客户端,因此我们的 MiniDB 是第一个这么干的(没有抄袭嫌疑!)

PostgreSQL 的通信协议文档在这里可以查阅:https://www.postgresql.org/docs/current/protocol.html

0x01 PostgreSQL 通信协议概览

翻到章节目录,我们可以看到它由以下几个部分组成:

  • Message Flow - 定义每一种数据包在什么时候发送,期望得到什么样的回应
  • Message Data Types - 定义数据包里会包含的数据类型
  • Message Formats - 定义每一种数据包的结构和含义
  • 通讯协议的更新日志
  • 一些其他的高级的东西,这不是我们目前需要考虑的东西

要让一个普通的 PostgreSQL 客户端能连上我们的服务端,我们只需要了解握手阶段查询阶段,至于别的细节,我们以后遇到了再考虑。

Start-up Phase:握手

最简单的握手流程如下图所示:

根据文档中的介绍,带有SSL支持的客户端在建立连接后,首先会发送一个 SSLRequest 数据包,询问服务端是否进行 SSL 加密。

服务端要么回复 S,然后双方进入 SSL 加密,或者回复 N,只进行明文通信。

接下来,客户端发送一个 StartupMessage,包含一些基本设置、用户名和数据库名字等信息,代表正式握手。

服务端会从 Authentication 系列的数据包中发送一个给客户端,表示请求客户端以某种方式进行认证。如果认证成功,则发送 AuthenticationOk 的数据包,代表认证成功。 如果认证失败,发送 ErrorResponse。

认证成功以后,客户端会等待服务端发送 ReadyForQuery 数据包,代表服务端准备完毕,可以进入查询阶段。在此期间,服务端可以发送一些 ParameterStatus 数据包给客户端,这些数据包中包含一些服务端的默认设置(如编码、时区等)。

Query Phase:查询阶段

PostgreSQL 有多种不同的查询模式,在这里我们先学习一下最简单的查询过程。

首先,客户端发送一个 Query 数据包,内含我们要查询的 SQL 语句(有可能是多条喔!)

服务端接到查询语句后进行查询,得到结果后返回。

如果得到的结果是一个表格(比如说执行了 SELECT 或者 EXPLAIN,服务端首先会发一个 RowDescription 的数据包介绍每一列的含义,然后每行数据一个 DataRow 数据包,然后以一个 CommandComplete 数据包作为结束,最后是一个 ReadyForQuery 等待下一条查询请求。

Message Formats: 数据包格式

通常来说,通信协议中的数据包一般要有这些信息:

  • 数据包标识符 - 用于区分数据包之间的类型
  • 数据包长度 - 用于告诉另外一端要准备多大的内存空间去接收数据
  • 正文 - 实际要发的内容

PostgreSQL 的数据包格式正是由这些要素组成的。

0x02 Netty 下的简易实现

说了这么多,该开始写代码了。

MiniDB 的网络部分代码结构是这样的:

MiniDB
│
├─com
│  └─lss233
│      └─minidb
│          └─networking
│              │  MessageType.kt
│              │  NettyServer.kt
│              │  NettyServerInitializer.kt
│              │  Session.kt
│              │
│              │  ├─query
│              │  │      QueryHandler.kt
│              │  │
│              │  └─startup
│              │          SSLRequestRejectHandler.kt
│              │          StartupMessageHandler.kt
│              │
│              └─packets
│                      AuthenticationOk.kt
│                      AuthenticationSASL.kt
│                      CommandComplete.kt
│                      EmptyQueryResponse.kt
│                      ErrorResponse.kt
│                      NotificationResponse.kt
│                      ParameterStatus.kt
│                      PostgresSQLPacket.kt
│                      Query.kt
│                      ReadyForQuery.kt
│                      RowDescription.kt
│                      SSLRequest.kt
│                      StartupMessage.kt
│                      Terminate.kt
│
└─Main.kt

NettyServerInitializer

这个类定义了数据包的处理顺序。

    @Throws(Exception::class)
    public override fun initChannel(ch: SocketChannel) {
        val session = Session()
        val pipeline = ch.pipeline()
        pipeline.addLast(PostgreSQLDecoder(session), PostgreSQLEncoder(session))
        pipeline.addLast(SSLRequestRejectHandler(session), StartupMessageHandler(session))
        pipeline.addLast(QueryHandler(session))
        pipeline.addLast(TerminateHandler(session))
    }

在 Netty 中,服务端每收到一个新的连接,就会生成一个新的 SocketChannel,代表这个连接。

这个连接收发的数据包会像工厂中的流水线一样一个环节一个环节地处理下去,这里的流水线就是 pipeline。

PostgreSQL 的数据包都是有结构的,所以在这条流水线的最前端,我们添加了数据解析器 PostgreDecoder 和数据编码器 PostgreEncoder,实现我们的对象和数据包之间的转化。

Session

PostgreSQL 中绝大多数的数据包都是以 1 字节的标识符开头(通常是一个有含义的字符),然后是 4 字节的数据包长度,后面才是数据包的具体信息。 而按照文档中的说法, SSLRequest、 StartupMessage 和 CancelRequest 这几个数据包由于历史原因,它们一开头直接就是数据包的长度,没有数据包标识符。

所以,我们需要一个独立的对象来保存连接的状态,这个对象在连接创建时产生,连接断开时消亡。

class Session {
    var state = State.Startup
    var user: String? = null
    var database: String? = null
    val properties = HashMap<String, String>()
    enum class State {
        Startup, Authenticating, Query, Terminated
    }
}

在这里,我定义了  Startup 握手、 Authenticating 认证、Query 查询和 Terminated 终止四种不同的状态。

客户端与MiniDB建立连接以后,先进入握手状态。此时发送的数据都是开头没有数据包标识符的。

当客户端发送 StartupMessage 之后,进入认证状态。接下来发送的数据包都是开头有标识符的。

认证成功以后进入查询状态,接下来才允许客户端发送查询命令。

当连接断开以后,进入终止状态,释放资源。

后续过程中,可能会加入更多的状态。

PostgreDecoder

在 decode 方法中,我们先判断状态,然后根据状态来选择数据包类型。

            val mType = if(session.state == Session.State.Startup) {
                val position = `in`.readerIndex()
                val length = `in`.readInt()
                val magicNumber = `in`.readInt()
                `in`.readerIndex(position)
                if(length == 8 && magicNumber == 80877103) { // 这是他们规定好的
                    MessageType.SSLRequest
                } else {
                    MessageType.StartupMessage
                }
            } else {
                MessageType.getType(`in`.readByte())
            }

根据数据包类型,构造专门的数据包对象,然后把剩下的数据交给对象处理。

            fun parse(type: MessageType?, payload: ByteBuf): IncomingPacket? {
                return when(type) {
                    MessageType.SSLRequest -> SSLRequest().parse(payload)
                    MessageType.StartupMessage -> StartupMessage().parse(payload)
                    MessageType.Query -> Query().parse(payload)
                    MessageType.Terminate -> Terminate().parse(payload)
                    else -> null
                }
            }

数据包

按照数据的传输方向,MiniDB 把数据分成了两个类型:IncomingPacket 和 OutgoingPacket,分别表示服务端会收到的数据包和服务端会发送的数据包。

interface PostgreSQLPacket {
}
interface OutgoingPacket: PostgreSQLPacket {
    fun write(buf: ByteBuf): OutgoingPacket
}
interface IncomingPacket: PostgreSQLPacket {
    fun parse(buf: ByteBuf): IncomingPacket
}

IncomingPacket#parse 方法将从数据流中读取数据,复制到自身的成员中;

OutgoingPacket#write 将根据自身成员变量的值往数据流中写入数据。

这两个方法的返回值都是对象自身,方便链式调用(完全是个人喜好)。

一个具体的类就像下面这样:

class ParameterStatus(private val key: String, private val value: String): OutgoingPacket {
    override fun write(buf: ByteBuf): OutgoingPacket {
        buf.writeCharSequence(key, StandardCharsets.UTF_8)
        buf.writeByte(0)
        buf.writeCharSequence(value, StandardCharsets.UTF_8)
        buf.writeByte(0)
        return this
    }
}

PostgreSQLEncoder

在编码过程中,我们先将数据存入一个空的数据流中(仅仅是为了知道它到底有多长)。

然后我们按照官方文档中描述的顺序往输出流写数据就行了。

    override fun encode(ctx: ChannelHandlerContext?, msg: OutgoingPacket?, out: ByteBuf?) {
        val mType = msg?.javaClass?.simpleName?.let { MessageType.valueOf(it) }

        val buf = Unpooled.buffer()
        msg?.write(buf)

        val type = mType?.type?.toInt() ?: '?'.code
        // '?' 代表未知标识符
        val len = buf.writerIndex() + 4
        println("<- ${msg?.javaClass?.simpleName}(${type.toChar()}) len $len")

        out?.writeByte(type)
        out?.writeInt(len)
        out?.writeBytes(buf, buf.writerIndex())
    }

Handlers

说完了数据包和编解码器,接下来就剩下包处理器了。

Netty 可以很聪明地把一些包只交给一些特定的处理器来处理。

我们只要根据包的类型来作出正确的反应,就可以让客户端成功地连接上我们。

举几个例子。

在刚开始握手的时候,客户端会问我们要不要 SSL 加密。

这种事情对我们来说为时尚早,所以我们果断回 NO!

class SSLRequestRejectHandler(private val session: Session) : SimpleChannelInboundHandler<SSLRequest>() {
    override fun channelRead0(ctx: ChannelHandlerContext?, msg: SSLRequest?) {
       // 咱们这幼小的 MiniDB 可玩不来 SSL 这种东西
        ctx?.writeAndFlush(Unpooled.copiedBuffer("N", StandardCharsets.UTF_8))?.sync()
    }

}

接下来客户端会给我们发送一个 StartupMessage 的数据包,里面会包含用户名和数据库名。

理论上,我们应该让客户端证明一下自己能访问数据库,我们会切换到认证阶段,给它发一个认证方法,然后等它的回复。

不过,因为这个过程比较复杂,我不想让繁琐的认证流程破坏我诱骗客户端的兴致,所以我决定很敷衍地告诉它:行了行了,你登录成功了。

并进入查询阶段。

class StartupMessageHandler(private val session: Session) : SimpleChannelInboundHandler<StartupMessage>() {
    override fun channelRead0(ctx: ChannelHandlerContext?, msg: StartupMessage?) {
//        session.state = Session.State.Authenticating
//        ctx?.writeAndFlush(AuthenticationSASL(listOf("SCRAM-SHA256")))?.sync()
//        极其敷衍告诉客户端:你登录成功了
        ctx?.writeAndFlush(AuthenticationOk())?.sync()
        ctx?.writeAndFlush(ParameterStatus("client_encoding", "UTF8"))?.sync()
        ctx?.writeAndFlush(ParameterStatus("DataStyle", "ISO, YMD"))?.sync()
        ctx?.writeAndFlush(ParameterStatus("TimeZone", "Asia/Shanghai"))?.sync()
        ctx?.writeAndFlush(ParameterStatus("server_encoding", "UTF8"))?.sync()
        ctx?.writeAndFlush(ParameterStatus("server_version", "14.5"))?.sync()
        session.state = Session.State.Query
        ctx?.writeAndFlush(ReadyForQuery())?.sync()
    }
}

QueryHandler

进入了查询阶段之后,我们的MiniDB就要处理客户端发过来的 SQL 语句了。

SQL 语句的解析部分由我的队友完成。我只要负责将 SQL 语句喂给他写好的 SQL 解析器,然后读取它的解析结果就行了。

按照官方文档的介绍,我们的服务端收到客户端的 SQL 语句之后要做出反应,否则客户端会一直傻傻地等待。

但由于我们的引擎部分啥也没写,所以现在就只能假惺惺回两句啦。

class QueryHandler(private val session: Session) : SimpleChannelInboundHandler<Query>() {
    private val REGEX_STMT_SET = Regex("set (.+) to (.+)");
    override fun channelRead0(ctx: ChannelHandlerContext?, msg: Query?) {
        try {

            var queryString = msg?.queryString

            // 先把查询语句转化为 MySQL 风格
            if(REGEX_STMT_SET.matches(queryString!!)) {
                queryString = queryString.replace(REGEX_STMT_SET, "SET $1=$2")
            }

            // 交给词法解析器
            val ast = SQLParserDelegate.parse(queryString)
            println("  Q(${ast.javaClass.simpleName}: $queryString")

            // 分析解析后的 SQL 语句,作出不同的反应
            when(ast) {
                is DMLSelectStatement -> {
                    // 这是一条查询语句
                    ctx?.writeAndFlush(RowDescription())?.sync()
                    //  查到了 0 条结果也是一种查
                    ctx?.writeAndFlush(CommandComplete("SELECT 0"))?.sync()
                }
                is DALSetStatement -> {
                    // 这是一条设置语句
                    for (pair in ast.assignmentList) {
                        // 更新设置
                        session.properties[(pair.key as SysVarPrimary).varText] =
                            pair.value.evaluation(emptyMap()).toString()

                        // 告知客户端设置成功
                        ctx?.writeAndFlush(CommandComplete("SET"))?.sync()
                        ctx?.writeAndFlush(ParameterStatus(
                            (pair.key as SysVarPrimary).varText,
                            session.properties[(pair.key as SysVarPrimary).varText]!!
                        ))?.sync()
                    }
                }
            }

        } catch (e: SQLSyntaxErrorException) {
            System.err.println(" Q(Error): ${msg?.queryString}")
            e.printStackTrace()
            // 告诉客户端你发的东西有问题
            val err = ErrorResponse()
            err.message = e.message!!
            ctx?.writeAndFlush(err)?.sync()
        }
        // 等待下一条语句
        ctx?.writeAndFlush(ReadyForQuery())?.sync()
    }

}

0x03 测试

使用 Navicat 连接我们的 MiniDB,没有出现任何报错就算成功!

不过在服务端,我们能看见 MiniDB 和 Navicat 聊得很开心!

虽然 Navicat 没有显示出任何数据库,但是根据他俩的聊天记录来看, Navicat 读取数据库列表其实是通过一条 SELECT 语句来实现的。

这提示我们在实现数据库引擎的时候,可以把数据库信息写到一个表里,这样就不需要再做额外的工作了。

0x04 花絮

在实际编写的过程中,我发现实际中的客户端和服务器会发一些文档里没提到的信息(或者说我没翻到),导致我写的服务端不能被正常连接。

遇到这种情况时,我们需要搭建一个真正的服务器,然后使用抓包工具来找到不一样的数据包。

在这里,我使用的是 sokit 的中继模式。上图为 Navicat 连接 PostgreSQL 数据库时双方的数据,下图是 Navicat 连接 MiniDB 时双方的数据。

至此, MiniDB 的网络通信部分先告一段落了。接下来就进入数据库引擎的开发,然后实现真正的数据库了。

MiniDB 开发手札1 - 概览

作者 Lss233
2022年10月3日 21:41

本学期一门叫《应用软件开发》的课程要求我们实现一个数据库系统,要求能索引、可持续化、多表连接查询、远程访问等功能。

简单思考了一下,我认为这个数据库应该分为以下几个部分:

看起来是一个挺复杂的工程,但慢慢实现一定是能成功的。

系统结构

对于网络通信这一部分,因为自己一直想试试 Netty 这个网络框架,但是又觉得 Java 写着无聊,于是便选择了 Kotlin 作为后端服务器的编程语言,顺便学习一下它的使用方式。

SQL 解析可以使用 ANTRL 来实现, GitHub 上也有大量的资料,应该不是很大的问题。

我希望这款数据库不仅可以作为像 MySQL 这样的分布式数据库服务器,还可以作为像 SQLite 这样的内嵌式数据库,因此还准备了一种数据库驱动和服务端代码一起打包并直接调用的方式,不过这个事情可以后面再说。

数据库引擎应该是整个系统最具有技术含量的部分,由于我没有系统地学习过任何一款数据库引擎实现,所以我对这部分内容是完全没有一点头绪——那也放到后面去吧!

此外,这个系统将由我和另外一位队友一起完成,为了确保我们可以在完成课程要求的前提下实现一些花里胡哨的功能,我们打算先从简单的功能写起,然后再让它逐渐变得复杂。

我认为实现这个数据库的过程也很有意思,所以打算写一系列开发手札,把这个过程记录下来,希望可以帮助到其他人去写类似的项目。

最后,附上我们的项目地址:

GitHub - lss233/MiniDB
Contribute to lss233/MiniDB development by creating an account on GitHub.
GitHublss233

TOJ 1175 - 线段树模板题

作者 Lss233
2021年1月11日 10:59

这题也是线段树

单组数据,第一行一个正整数n。(1<=n<=10^5)

第二行n个数 a1,a2...an。(0<= |a[i]| <=10^7)

第三行一个整数m,表示m个操作。 (1<=m<=10^5)

接下来m行,每行第一个数表示操作类型,其余数表示操作对应的参数,对应题面。 (1<=l<=r<=n,0<=|v|<=10^7)

输入

单组数据,第一行一个正整数n。(1<=n<=10^5)

第二行n个数 a1,a2...an。(0<= |a[i]| <=10^7)

第三行一个整数m,表示m个操作。 (1<=m<=10^5)

接下来m行,每行第一个数表示操作类型,其余数表示操作对应的参数,对应题面。 (1<=l<=r<=n,0<=|v|<=10^7)

输出

对于每一个3操作,输出一行三个整数,表示区间和,最大值,最小值。

样例输入

5
1 2 3 4 5
7
1 1 1 -2
3 1 2
1 3 5 1
3 1 5
3 3 3
2 3 3 3
3 3 3

样例输出

1 2 -1
16 6 -1
4 4 4
3 3 3

题解

这题应该算是线段树的模板题了吧。最大值、最小值、求和、区间改值都有了。
所以特此记录一下。

代码

#include <bits/stdc++.h>
#define L(x) (x << 1)
#define R(x) (x << 1 | 1)
typedef long long ll;
const int MAX_N = 2e5 + 10;
struct leaf
{
    ll max, min, sum;
    leaf()
    {
        max = 0, min = 0, sum = 0;
    }
} tree[MAX_N * 4];
ll data[MAX_N], lazy_add[MAX_N * 4], lazy_set[MAX_N * 4];
bool is_lazy_set[MAX_N * 4];
void push_up(int k)
{
    tree[k].sum = tree[L(k)].sum + tree[R(k)].sum;
    tree[k].max = std ::max(tree[L(k)].max, tree[R(k)].max);
    tree[k].min = std ::min(tree[L(k)].min, tree[R(k)].min);
}
void push_down(int k, int a, int b)
{
    int m = (a + b) >> 1;
    if(is_lazy_set[k]) {
        lazy_add[L(k)] = lazy_add[R(k)] = 0;
        lazy_set[L(k)] = lazy_set[R(k)] = lazy_set[k];
        is_lazy_set[L(k)] = is_lazy_set[R(k)] = is_lazy_set[k];
        tree[L(k)].sum = (m - a + 1) * lazy_set[k];
        tree[L(k)].max = lazy_set[k];
        tree[L(k)].min = lazy_set[k];

        tree[R(k)].sum = (b - m) * lazy_set[k];
        tree[R(k)].max = lazy_set[k];
        tree[R(k)].min = lazy_set[k];
        is_lazy_set[k] = false;
    }
    if(lazy_add[k]) {
        lazy_add[L(k)] += lazy_add[k];
        lazy_add[R(k)] += lazy_add[k];
        tree[L(k)].sum += (m - a + 1) * lazy_add[k];
        tree[L(k)].max += lazy_add[k];
        tree[L(k)].min += lazy_add[k];

        tree[R(k)].sum += (b - m) * lazy_add[k];
        tree[R(k)].max += lazy_add[k];
        tree[R(k)].min += lazy_add[k];
        lazy_add[k] = 0;
    }
}
void build(int k, int l, int r)
{
    if (l == r)
    {
        tree[k].max = tree[k].min = tree[k].sum = data[l];
        return;
    }
    int mid = (l + r) >> 1;
    build(L(k), l, mid);
    build(R(k), mid + 1, r);
    push_up(k);
}
void update(int k, int a, int b, int l, int r, int op, ll val)
{
    if (a <= l && r <= b)
    {
        if (op == 1) // add
        {
            tree[k].max += val;
            tree[k].min += val;
            tree[k].sum += val * (r - l + 1);
            lazy_add[k] += val;
        }
        else if (op == 2) // set
        {
            tree[k].max = val;
            tree[k].min = val;
            tree[k].sum = val * (r - l + 1);
            lazy_set[k] = val;
            lazy_add[k] = 0;
            is_lazy_set[k] = true;
        }
        return;
    }
    int mid = (l + r) >> 1;
    push_down(k, l, r);
    if (a <= mid)
    {
        update(L(k), a, b, l, mid, op, val);
    }
    if (mid < b)
    {
        update(R(k), a, b, mid + 1, r, op, val);
    }
    push_up(k);
}
leaf query(int k, int a, int b, int l, int r)
{
    if (a <= l && r <= b)
    {
        return tree[k];
    }
    push_down(k, l, r);
    int mid = (l + r) >> 1;
    leaf ans;
    if (a <= mid && mid < b)
    {
        leaf resA = query(L(k), a, b, l, mid);
        leaf resB = query(R(k), a, b, mid + 1, r);
        ans.max = std ::max(resA.max, resB.max);
        ans.min = std ::min(resA.min, resB.min);
        ans.sum = resA.sum + resB.sum;
        return ans;
    }
    if (a <= mid)
    {
        return query(L(k), a, b, l, mid);
    }
    if (mid < b)
    {
        return query(R(k), a, b, mid + 1, r);
    }
    return ans;
}
int main()
{
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
    {
        scanf("%lld", &data[i]);
    }
    build(1, 1, n);
    int m;
    scanf("%d", &m);
    while (m--)
    {
        int op, l, r;
        scanf("%d%d%d", &op, &l, &r);
        if (op < 3)
        {
            ll val;
            scanf("%lld", &val);
            update(1, l, r, 1, n, op, val);
        }
        else
        {
            leaf res = query(1, l, r, 1, n);
            printf("%lld %lld %lld\n", res.sum, res.max, res.min);
        }
    }
}

HDU 1671 - Phone List

作者 Lss233
2021年1月7日 18:01

Given a list of phone numbers, determine if it is consistent in the sense that no number is the prefix of another. Let’s say the phone catalogue listed these numbers:

  1. Emergency 911
  2. Alice 97 625 999
  3. Bob 91 12 54 26
    In this case, it’s not possible to call Bob, because the central would direct your call to the emergency line as soon as you had dialled the first three digits of Bob’s phone number. So this list would not be consistent.

Input

The first line of input gives a single integer, 1 <= t <= 40, the number of test cases. Each test case starts with n, the number of phone numbers, on a separate line, 1 <= n <= 10000. Then follows n lines with one unique phone number on each line. A phone number is a sequence of at most ten digits.

Output

For each test case, output “YES” if the list is consistent, or “NO” otherwise.

Sample Input

2
3
911
97625999
91125426
5
113
12340
123440
12345
98346

Sample Output

NO
YES

题解

题目会输入一组数字。要求任意一组数字不得是另外一组的前缀。
比如说:

91125426
911

或者

812345
8123456

这样的话只需要线段树在插入的时候,判断经过的节点是否被标记为 end
或者在插入结束后,判断最后一个节点是否为第一次被插入就好了。

另外,本题的内存限制比较小,
所以在一组数据的结束,需要释放内存(就算你不说我也会这么做的,因为一个 delete 清空字典树超方便呀)。

#include <bits/stdc++.h>
const int MAX_CHILDREN = 11;
struct node
{
    bool end;
    node *children[MAX_CHILDREN];
    int count = 0;
    ~node() {
        for (int i = MAX_CHILDREN - 1; i >= 0; i--)
        {
            if(children[i]) {
                delete children[i];
            }
        }
    }
    node()
    {
        end = false;
        for (int i = 0; i < MAX_CHILDREN; i++)
            children[i] = NULL;
    }
};
node *root;
bool yes = true;
/**
 * 插入一个新的字符串
 * @param str 字符串数组
 * @param len 字符串长度
 **/
void insert(char *str, int len)
{
    node *location = root;
    for (int i = 0; i < len; i++)
    {
        if (!yes)
            return; // Thereisnoneed.
        if (str[i] == 0 || str[i] == '\n')
            continue;
        int id = str[i] - '0';
        if (location->children[id] == NULL)
        {
            location->children[id] = new node;
        }
        if (location->end)
        {
            yes = false;
            return;
        }
        location = location->children[id];
        location->count++;
    }
    location->end = true;
    if (yes)
    {
        yes = location->count == 1;
    }
}
char str[20];
int main()
{
    int T;
    scanf("%d", &T);
    while (T--)
    {
        root = new node;
        yes = true;
        int n;
        scanf("%d", &n);
        while (n--)
        {
            scanf("%s", str);
            int len = strlen(str);
            insert(str, len);
        }
        printf("%s\n", yes ? "YES" : "NO");
        delete root;
    }
}

HDU 1257 - 统计难题

作者 Lss233
2021年1月7日 11:11

Ignatius最近遇到一个难题,老师交给他很多单词(只有小写字母组成,不会有重复的单词出现),现在老师要他统计出以某个字符串为前缀的单词数量(单词本身也是自己的前缀).

输入

输入数据的第一部分是一张单词表,每行一个单词,单词的长度不超过10,它们代表的是老师交给Ignatius统计的单词,一个空行代表单词表的结束.第二部分是一连串的提问,每行一个提问,每个提问都是一个字符串.

注意:本题只有一组测试数据,处理到文件结束.

输出

对于每个提问,给出以该字符串为前缀的单词的数量.

样例输入

banana
band
bee
absolute
acm

ba
b
band
abc

样例输出

2
3
1
0

题解

所以,所谓的字典树就是把一个字符串都存进一棵树,每个节点都放一个字符。所以,对于每个节点至多会有 26个子节点。
然后我们可以在节点上存一点别的玩意,来满足题目想要的效果。
比如说, end 表示这是最后一个字符,那么从根节点到这个节点所经过的字符就正好构成了这个
词。
count 表示这个字符在同样的位置出现了多少次。
etc.

代码

#include<bits/stdc++.h>
#include <stdio.h>
struct node {
    bool end;
    node * children[26];
    int count = 0;
    ~node() {
        for (int i = 25; i >= 0; i--)
        {
            if(this->children[i] != NULL) {
                this->children[i]->~node();
                delete this->children[i];
            }
        }
    }
    node() {
        end = false;
        for(int i = 0; i < 26; i++)
            children[i] = NULL;
    }
};
node * root;
/**
 * 插入一个新的字符串
 * @param str 字符串数组
 * @param len 字符串长度
 **/
void insert(char* str, int len) {
    if(!root) {
        root = new node;
    }
    node *location = root;
    for(int i = 0; i < len; i++) {
        if(str[i] == 0 || str[i] == '\n') continue;
        int id = str[i] - 'a';
        if(location->children[id] == NULL) {
            location->children[id] = new node;
        }
        location = location->children[id];
        location->count++;
    }
    location->end = true;
}
/**
 * 查找指定字符串在树中的长度
 * @param str 字符串数组
 * @param len 字符串长度
 **/
int search(char * str, int len) {
    node * location = root;
    for (int i = 0; i < len; i++)
    {
        int id = str[i] - 'a';
        if(location->children[id] == NULL)
            return 0;
        location = location->children[id];
    }
    return location->count;
}
char str[20];
int main() {
    while (1)
    {
        fgets(str, 15, stdin);
        int len = strlen(str);
        if(len == 1) {
            break;
        }
        insert(str, len);
    }
    while (~scanf("%s", str))
    {
        int len = strlen(str);
        if(len == 0) {
            break;
        }
        printf("%d\n", search(str, len));
    }
    
}

这个Lss233一事无成却敢写年末总结:2019,再见啦。

作者 Lss233
2020年1月1日 11:11
这个Lss233一事无成却敢写年末总结:2019,再见啦。

你是活了365天,把1天过了365遍?
也许是缺少了一些仪式感,我发现这一年过得好快。

过去的一年里,Lss233做了些什么?

在写这篇文章的时候,咱先回顾了一下18年写的年末总结。很棒,消极的预言都实现了,积极的预言一个也没有。

Added: こんにちわ

‘学习一门新的外语。’ 我记得写这句话的时候,我在Duolinguo自学法语。后来因为某种原因没有坚持下去了。当初的想法是先自学一些拉丁语系的语言,最后学日语。结果……我现在打算主攻日语啦。

Updated: 更新了人生规划,未来是迷茫的

记得我最初的人生规划是,报考计算机系,毕业之后在大企工作。不过我要放弃这个规划了,19年里看见了不少程序员猝死、被公司压榨的报道……
我希望我努力奋斗的结果换来的是比现在更轻松的生活,而不是更艰难的。

‘寻找新的机遇。’很成功,现在我有自己的想法了。如果让我自己来的话,也许是一个更好的选择?

我不知道。这是两条完全不同的路。我害怕。

Added: 呐,咱也是二次元了

某日在知乎上看到有人提到*《小林家的龙女仆》*,看完之后便一发不可收(拾)。
最喜欢的出版社:芳文社,最喜欢的类型:日常,百合。
最喜欢的角色:涼風青葉!!!
这个Lss233一事无成却敢写年末总结:2019,再见啦。

日本是应该是世界上自杀率最高的国家,但是在番剧里看到的满是理想化的美好世界。二次元是一批人的精神寄托吧。
这个Lss233一事无成却敢写年末总结:2019,再见啦。

我很喜欢像花火大会那样的场景,日本的传统文化有不少是来自于古代的中国,遗憾的是我在现实世界没能参与这种有仪式感的活动。也许是没有保留下来?这是我对日本好奇的原因之一。

(这里没有配图)

受到某些番的影响,咱也开始试着画画和写轻小说了。因为想看后续和玩相关的游戏,我开始学习日语。

在Bilibili上我追了47部番,在Pixiv上我发表了1个作品,认识了1位日本网友。

Added: 关于我精神状态的问题

于9月28日我在Telegram上创建了一个名为 Lss233.thoughts(); 的频道,本想发表一些自己的观点,但后来变成了自己内心的精分小剧场。没关系,反正没人看的吧。
我可以确定自己存在一些精神上或者心理上的问题,所以如果我出了什么事,这里面发布的消息应该可以推导出我自杀前的心理状态。

在过去的一年里,我共发表了168条消息。

Removed: Minecraft圈子

今年穆冉阁没有开服。我已经确定自己不再活跃于这个圈子了。当初玩这个游戏的人已经不再是现在玩这个游戏的人。我已经找不到当初的那种乐趣了。

我很喜欢小众的圈子,但一旦它大众化之后我就会变得厌恶。进来的人叽叽喳喳,让我找不到原来的和谐感,也改变了我们最初的目的。所以,我还是选择离开。

Removed: coding

前半年与@Shawhoi参与了PokeMarket项目,获得了不少收益,非常感谢。
后半年参与了0个项目,共发布了0个作品,写了约0行代码。

总结

如果让18年写年末总结的Lss233来评价这一年,展望达成率67%还是很不错的。
但让现在的我评价,还是有一些遗憾。明明可以更努力,但是却没有。只要一遇到麻烦,我就会焦虑……我为什么这么没用?我一直在让自己变得更合群,但也一直在疏远周围的人。我真是矛盾……

但无论如何,生活还是需要继续的。用凉风青叶的话来说,那就是「今日も一日がんばるぞい!」。

最好的祝愿给2020年的Lss233。

--- Lss233,写于2020年的第一天。

噩梦24小时:记一次服务器迁移与宕机过程

作者 Lss233
2019年2月9日 16:36

有句俗语叫:If it ain't broke, don't fix it. 今天真是见识到了。

0x0000 起因

在昨天(2月8日) 17时,本人收到了Cloudcone发来的春节优惠邮件。

在一番激烈的思想斗争下,下单了一个看上去更便宜的套餐。打算把服务器迁移过去。

0x0001 噩梦

几分钟后,服务器创建完毕。

发现控制面板居然没有像搬瓦工那样方便的服务器迁移菜单,自己又懒得手动迁移,于是就发了份工单给客服询问有没有什么办法。

17:30

技术人员小哥开始帮我迁移数据。

他的操作非常简单粗暴,直接把旧服务器的硬盘挂载到新服务器上,再把新服务器的硬盘挂载到旧服务器上。

听起来是没什么问题,那位小哥也没继续回复我。

然而他没有料到,两台服务器的IP地址是静态设置的,写死在`/etc/networks/interfaces`里,这位小哥没有给我改过来,这导致两台服务器都不能访问网络。

无奈之下,我只好回复他,请他帮忙修改地址,顺便帮我调整好硬盘大小。

次日0:00

距离我的回复已经过去3个小时,

那位技术人员小哥没有回复

见鬼了,怎么办?!

我打开面板,看见有一个开启IPv6的选项。 开启IPv6应该会重新配置网络,说不定能把IP问题修好呢?

抱着侥幸的心理,我点了。服务器重新启动。

几分钟后,成功了。服务器可以上网了!

然而,咱又遇到了新的问题。这个服务器的IP地址比较特殊,它在国内访问会显示连接超时,而在其他地方却没有问题。很明显,这个服务器的IP被[数据删除]了。

0:30

我还没有睡。在百度上稍微搜索了一下,得到两种解决方法:

  1. 删除服务器,重新购买。
  2. 发工单申请更换IP,但需要$2。

由于之前的技术小哥帮我把硬盘挂载在了新服务器上,如果直接删除,那么数据也肯定跟着一起没掉了,所以排除方法1。

我创建了一个Urgent工单,申请更换IP,。

几分钟后

很快,就有一个工程师回复我了。他是这样说的:Your request is sent to our network team, please allow some time while they get it processed for you.

我的内心:诶,不错啊这服务态度。

抱着侥幸的心理,我在这个工单里问能不能帮我把硬盘大小的问题也给处理了。

我刚刚回复完。刷新了一下浏览器,就看见另外一个工程师回复我:Your request is sent to our dev-ops team, please allow some time while they get it processed for you.

我:???

完了。这俩人是机器人没跑了。

看自己的账户,$2也没有扣掉。先睡觉吧,说不定明天早上就好了呢。

睡之前回复了一句:Thank you, please be quick. My service have been shutdown for over 8 hours, my users are complaining, please understand.

10:00

我大概是这个时候醒的。登录网站看看情况解决了没有。

最早帮我迁移的小哥还是没有回复我。第二个工单在我回复没多久之后就回复了一句:你可以在我们dev-ops回复之前照常使用你的服务器。

照常个鬼啊,根本用不了啊。这都什么鬼回复模板。

11:00

不等了,估计他们是打算直接把我凉着了。

现在我有两台服务器:

  1. 本来就有的一台 chocolate,挂载硬盘30GB,无法访问网络。
  2. 前面下单的一台 darksky,挂载硬盘25GB,可以访问网络。

想了一个简单的思路:

  1. 重新安装chocolate上的系统,腾出空间。
  2. 使用 ssh 和 dd 把 darksky上的硬盘复制到 chocolate 上。
  3. 删除 darksky。
  4. 重新下单一台服务器。
  5. 把在chocolate上的数据迁移到新下单的服务器上。

看上去挺简单的,就像写程序的时候交换两个变量一样。

11:18

下单了新的服务器,系统安装好之后惊呆了,IP地址和之前的居然是一样的。

删除,重新下单。

又是一样的。

看来他们还是按顺序提供分配IP的,也就是说,必须要有一个倒霉鬼下单得到那个IP之后,我下单才会得到新的IP。

这大中午的,谁会买啊。

11:22

我想到了一个绝妙的好主意。

先下单一个价格最低的服务器,它会分配到那个不能用的IP。再下单我想要的服务器,这样就可以啦!

0x0002 第二个噩梦

复制数据很简单是吧?我一开始也是这样想的。

在百度上搜索了一下,原来有种东西叫 SSHFS。 它可以通过SSH协议把远程的服务器挂载到本地上。

于是我的做法是:把硬盘镜像mount到chocolate/mnt上,再通过SSHFS把chocolate/mnt 挂载到新服务器的 /mnt上。

11:52

重启新服务器,发现使用旧的用户名和密码可以登陆。

太棒了!

诡异的事情来了,登陆成功之后,还没有看见bash,就重新退回到登陆界面了。现在想想,应该是某个支持库没有复制过来,导致bash无法启动。

看来SSHFS这条道不行,重新安装。 对了,新的服务器叫vanilla。

很久以前曾经用tar配合netcat迁移过数据。通过管道,分别在发送端和接收端执行:

# 接收端
netcat -l -p 7000 | tar x
# 发送端
tar cf - * | netcat 接收服务器 7000

试试吧。

14:46

我把MobaXterm自带的小游戏玩了个遍。终于知道为什么一个好好的终端软件带这么多游戏干什么了。

我重试了好几次,每次都是执行到一半,ssh断开连接,Vanilla整个系统无法响应。

看来这个方法也是不行了。应该是因为在覆盖的某个重要lib文件的时候整个系统崩溃。

这个服务商提供的服务器都是KVM,可以访问Recovery模式,看来只能用这种方法了。

Recovery模式和Windows的PE系统差不多,是挂载到内存上的系统。因此我们怎么修改硬盘都和它没有关系。

16:00

Recovery的系统可真是简洁。没有apt,也没有gcc。这意味着我用不了SSHFS这种方法了。

不过ssh和scp倒是能用。我挂载了本地硬盘,使用scp把远程上的服务器复制到本地。

问题#1

SCP复制的时候进度一直是0%,而且最后的文件大小居然超过了25GB!

一看才发现,整个程序卡在复制虚拟设备 /dev/core 上。

问题#2

历尽千辛万苦终于复制完数据,回到面板把Recovery模式关闭。这个按钮点了半天没有反应。他们面板的开发人员怕不是写了个假的按钮吧。

好在本人对Html和JavaScript也略知一二,打开F12看了一下,是一个表单。手动执行一下submit();完事。

16:15

成功进入系统了,但是发现好像少了很多东西,和没有复制几乎没区别。

什么玩意啊。

无奈,还是只能走SSHFS的方法。

0x0003 重新上线

功夫不负有心人,在走了N次弯路之后,服务器终于恢复正常了。

16:25

在复制lib文件夹的时候。第一次把文件复制错了位置。从 /mnt/usr/lib 复制到了 /usr/lib/里。第二次执行到一半,发现在覆盖 libglib的时候,SSHFS废了。

庆幸自己第一次复制错了没有删除,为了安全起见我在VNC下完成了覆盖。

18:09

MySQL不知道因为什么原因无法启动,日志报错Could notcreate unix socket lock file /var/run/mysqld/mysql.sock.lock。

在尝试了各种方法之后,发现把socket的路径指向 /tmp 里面就可以了。

/tmp/var/run 都挂载在tmpfs下。我在复制的时候没有注意,覆盖了 /var/run

也就是说,重启可以解决问题。

18:11

网站成功上线!

截止至网站上线,两个工单还是没有人回复,之前换IP的申请也没有扣款。

虽然网上很多人表示Cloudcone的工单都是秒回,但这次的事件真的让我担心日后的使用。

就这样吧。

2019-2-9 20:34:19

❌
❌