在Haskell中构造SQL服务器教程

324 阅读10分钟

简介

在FP Complete,我们为客户开发了许多工具,以帮助他们实现其目标。这些工具大多是用Haskell编写的(最近还有一些是用Rust编写的),这有助于我们更快速地编写这些工具,并在未来产生更多可维护的应用程序。

在这篇文章中,我想描述其中的一个工具,任何拥有SQL服务器的现有或遗留数据库的人或公司都应该感兴趣。

一个客户正在从SQL Server全面迁移到PostgreSQL,然而,像许多大公司一样,他们有许多数据库分布在全国各地的不同服务器上,并由不同部门的许多人使用。要简单地替换一个很多人都在直接使用的数据库并不那么容易。他们仍然希望为他们的各个部门提供一个SQL Server的查询界面,所以他们问我们是否可以开发一个独立的服务,可以假装是SQL Server,但实际上在幕后与任何JSON服务对话。我们做到了!本文将介绍它是如何完成的。

需求和架构

该系统的高层架构是这样的:

No alt provided

要求:

  • 该服务应该接受并理解最小的SQL(即告诉我查询哪个 "表",也许还有一个微不足道的WHERE 子句)。
  • 它应该查询一些提供真正数据库的后台JSON/网络服务。
  • 它应该正确地返回表格式的结果。
  • 服务器必须要有内存效率,因此必须是流式的
  • 它应该用Haskell编写,以便客户可以维护它。

因此,系统的用户会写一个普通的SQL服务器查询,比如SELECT * FROM "customer" ,然后被发送到一个假的SQL服务器,而这个服务器又会查询真正的后台服务,不管那是什么,并把结果返回给用户。

方法

为了实现一个假的SQL服务器,我们需要:

  • 一个服务器程序,接受来自客户端的连接,并谈论他们的语言。
  • 一个客户程序,它是一个真实的SQL Server客户,我们可以用它来测试我们的服务器。

我研究了SQL Server和它的客户端所使用的协议。它被称为TDS,意思是 "表格式数据流"。它最初是由Sybase公司在1984年为其Sybase SQL Server关系数据库引擎设计和开发的。后来,它被微软公司采用,用于Microsoft SQL Server。就二进制协议而言,它是一个相当直接的二进制协议。微软有TDS协议的文档,你可以在自己的时间里看一看。

关于客户端程序,今天在Linux或Mac OS X中访问微软的客户端库要容易得多,这要感谢他们发布的ODBC包。你现在可以在Linux和macOS上得到它了!我也很惊讶!这使得为我们的服务器写一个测试套件变得很容易。

TDS如何工作

TDS是一个二进制协议。通信是通过消息来完成的,每个消息都是一个所谓的数据包序列,每个数据包由一个头和(通常,但不总是)一个有效载荷组成。

数据包

头部有8个字节,由下表描述。我已经划掉了我们不使用的部分。

标头

字段类型描述
类型字8不被我们使用
状态字80=正常信息,1=信息结束
长度字16数据包的长度,大殷商
识别码字16不被我们使用
数据包字8不被我们使用,被SQL服务器忽略
窗口字8不使用,应该被忽略

事实证明,我们只需要statuslength 字段!

  • Status 告诉我们在这个请求中是否有更多的数据包,或者这是否是最后一个。
  • Length 告诉我们整个数据包的长度,包括头本身。

一个典型的场景:

  • TDS协议以一个从客户端到服务器的称为预登录的消息开始。在预登录中,你可以指定你是否要启用联合认证或加密。在我们的案例中,我们不支持联合登录,因为反正没有一个客户在使用它。我们也不支持加密。
  • 客户端可以请求或要求打开该功能,或者说它根本不支持加密。利用这些信息,服务器协商是否启用加密功能,在我们的案例中,加密功能被完全关闭。
  • 如果服务器对预登录信息感到满意,服务器应该发回一个预登录响应。之后,服务器应该预计到登录请求,这将包括登录细节,如用户名和密码。
  • 一旦进行了协商,我们就可以进入交互循环,在这个循环中,服务器接受请求,处理请求并返回结果。

比如说:

  • 一个简单的批量查询,包括一个SQL查询,并返回一个结果列表。
  • 另一种类型的消息可以是一个RPC调用,SQL服务器使用它来执行函数来准备语句等等。

我们处理过这两种类型的消息,但我们在这篇文章中只看简单的批处理查询。

堆栈-专栏-中心

服务器架构

我的第二步是启动一个Haskell项目,并计划我将用于这项任务的库。

我使用了这些Haskell包:

  • conduit-extra来创建一个监听多线程的 TCP 服务。
  • conduit本身用于在服务器和客户端之间进行数据交换。
  • attoparsec来解析TDS协议。
  • bytestringbuilder,为TDS协议生成消息。

在图表中,该管道看起来像这样:

No alt provided

我们将详细看看每一个。

流处理:Conduit

Conduit是用来实现流处理的。其他语言如Rust称这些迭代器,或者在Python中它们被称为生成器(数据生产者)和coroutines(数据消费者)。在Haskell中,它被实现为一个普通的库。它是一个概念上简单的流媒体API,由两个真正的关键部分组成。

  • await - 停止你正在做的事情,等待流中的下一个数据块。
  • yield - 向下游的await提供一个数据块。

我们在服务器中使用了这些东西,我用于这项任务的所有库也是如此。

考虑一下这个流水线的例子。

pipeline =
  source .| conduit1 .| conduit2 .| sink

(注意:.| ,可以像UNIX管道一样阅读。)

源可能是一个套接字,一个文件或一个数据库。这三种I/O来源的块可以产生字节,或者例如来自数据库查询的行。在一个管道中,汇(管道中的最后一个东西)驱动计算,每次等待输入时都会在管道中产生多米诺骨牌效应,一直回到源头,源头咨询一些外部提供者(socket、文件或数据库)。

No alt provided

我使用了 conduit-extra,因为你可以用三行字就把一个监听服务组合起来。这看起来像这样:

#!/usr/bin/env stack
-- stack --resolver lts-12.12 script
{-# LANGUAGE OverloadedStrings #-}
import Data.Conduit.Network
import Conduit

main =
  runTCPServer
    (serverSettings 2019 "*")
    (\app -> do
       putStrLn "Someone connected!"
       runConduit (appSource app .| mapMC print .| sinkNull)
       putStrLn "They disconnected!"
       pure ())

在这个例子中,来自套接字的源管道被输送到一个管道(mapMC print ),这个管道只是打印每个进来的块。最后,它被输送到sinkNull ,它消耗所有的输入并丢弃它。

运行它看起来像这样。

$ ./conduit-tcp-server.hs
Someone connected!
"hi\n"
They disconnected!

我们将逐步建立一个简单的服务器实例。

如果你想跟随并在你的电脑上运行这些Haskell例子,当你安装了stack ,它们可以方便地作为常规脚本运行。请查看我们的 "开始"页面,按照说明进行设置。

类型安全

Haskell中的大多数库都以类型安全为荣,像Conduit这样的流式库也不例外。让我们简单看看这个。这是一个导管的类型:

data ConduitT input output monad return

它意味着 "一个管道有一个input ,一个output ,运行在一些monad ,并且有一个最终的return "。

因此,例如,yield 有这种类型:

yield :: Monad m => input -> ConduitT input output monad ()

它产生一个下游的输入,并返回单位()

await 则有这种类型:

await :: Monad m => ConduitT input output monad (Maybe output)

它等待来自上游的输出,并可能返回Just ,如果上游还有什么东西。否则,它返回Nothing

mapfilter 这样的函数也有教育类型。

map :: Monad m => (input -> output) -> ConduitT input output monad ()
filter :: Monad m => (input -> Bool) -> ConduitT input input monad ()

最后,把它们插在一起必须要有正确的类型。

filter (> 5) .| map show .| filter (=/ "6") .| ...

这让我们可以像乐高积木一样把碎片插在一起,确信我们的组成是正确的。输入、输出和返回都必须匹配起来。

增量解析:attoparsec

我使用attoparsec来解析二进制协议。attoparsec是一个解析器组合库,支持对输入的不完全解析,即分块解析数据。

下面是一个简单的、封闭的例子。

#!/usr/bin/env stack
-- stack --resolver lts-12.12 script
{-# LANGUAGE OverloadedStrings #-}
import qualified Data.ByteString as S
import qualified Data.Attoparsec.ByteString as P

main =
  case P.parseOnly myparser (S.pack [2, 97, 98]) of
    Right result -> print result
    Left err -> putStrLn err
  where
    myparser = do
      len <- P.anyWord8
      bytes <- P.take (fromIntegral len)
      return bytes

其中输出的内容如下。

$ ./attoparsec-example.hs
"ab"

如果我创建一个流媒体程序,像这样把一个字节的块送入解析器。

#!/usr/bin/env stack
-- stack --resolver lts-12.12 script
{-# LANGUAGE OverloadedStrings #-}
import qualified Data.ByteString as S
import qualified Data.Attoparsec.ByteString as P

main = loop (P.parse myparser) (S.pack [2, 97, 98])
  where
    loop eater input =
      do putStrLn ("Chunk " ++ show (S.unpack chunk))
         case eater chunk of
           P.Done _ value -> print value
           P.Fail _ _ err -> putStrLn err
           P.Partial next -> do
             putStrLn "Waiting for more ..."
             loop next remaining
      where (chunk, remaining) = S.splitAt 1 input
    myparser = do
      len <- P.anyWord8
      bytes <- P.take (fromIntegral len)
      return bytes

我们创建一个带有 "食客 "的循环。这个吃货吃的是字节块。它要么产生一个完成/失败的结果,要么产生下一个准备吃下一个块的吃者。

输出是:

$ ./attoparsec-feeding.hs
Chunk [2]
Waiting for more ...
Chunk [97]
Waiting for more ...
Chunk [98]
"ab"

然而,请注意,myparser 并没有改变。它并不关心输入是如何分块的。它很有耐心。最终的结果是一样的。这在 conduit 中工作得非常好,自然, conduit 与来自 conduit-extra 包的 attoparsec 集成。

上面的代码可以在 conduit 中重写为:

#!/usr/bin/env stack
-- stack --resolver lts-12.12 script
{-# LANGUAGE OverloadedStrings #-}
import qualified Data.ByteString as S
import qualified Data.Attoparsec.ByteString as P
import Conduit
import Data.Conduit.List
import Data.Conduit.Attoparsec

main = do
  result <- runConduit (sourceList chunks .| sinkParserEither myparser)
  case result of
    Left err -> print err
    Right val -> print val
  where
    chunks = [S.pack [2], S.pack [97], S.pack [98]]
    myparser = do
      len <- P.anyWord8
      bytes <- P.take (fromIntegral len)
      return bytes

生产:

$ ./attoparsec-conduit.hs
"ab"

看看这个例子和上面的例子,很容易想象到sinkParserEither 是如何运作的。它从上游消耗块,并递增地将其送入myparser ,直到发生结果。

高效的二进制写作:字节串联

最后,为了生成TDS协议的消息,我可以使用bytestring Builder抽象有效地生成二进制流。从本质上讲,这个抽象让你在缓冲区中插入字节串,它们被有效地追加。此外,这些字符串可以以流的方式增量写入套接字或文件,因此也很节省内存。

具体到我们的用例,它使输出涉及word8、word16的二进制格式变得微不足道,并且很容易区分little-endian和big-endian编码。

例如,为了建立我们上面的例子,我们会使用<> ,将各块内容组合在一起。

#!/usr/bin/env stack
-- stack --resolver lts-12.12 script
{-# LANGUAGE OverloadedStrings #-}
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.Builder as BB
main = L.putStr (BB.toLazyByteString (BB.word8 2 <> "ab"))
$ ./builder-example.hs
ab

或者可以把它写成一个管道:

#!/usr/bin/env stack
-- stack --resolver lts-12.12 script
{-# LANGUAGE OverloadedStrings #-}
import           System.IO
import           Data.Conduit
import qualified Data.Conduit.List as CL
import qualified Data.Conduit.Binary as CB
import qualified Data.ByteString.Lazy as L
import qualified Data.Conduit.ByteString.Builder as CB
import qualified Data.ByteString.Builder as BB

main =
  runConduitRes
    (CL.sourceList [BB.word8 2 <> "ab"] .| CB.builderToByteString .|
     CB.sinkHandle stdout)

也就是说,产生一个构建器的来源。把它送入一个管道,该管道将构建器转换为字节字符串流,然后把它送入一个写入stdout的汇中。

实现:解析器

考虑到所有这些,消息分析器看起来像这样

messageParser :: Parser (Packet ClientMessage)
messageParser = do
  header <- headerParser
  let payloadLength = fromIntegral (headerLength header - headerSize)
  message <-
    case headerType header of
      0x01 -> sqlbatchParser payloadLength
      0x12 -> preloginParser payloadLength
      0x0E -> transactionParser
      0x03 -> rpcParser payloadLength
      0x10 -> loginParser
      0x06 -> attentionParser
      _ -> do
        payload <- Atto.take payloadLength
        pure (UnknownMessage payload)
  pure (Packet header message)

例如,sqlbatchParser

sqlbatchParser :: Int -> Parser ClientMessage
sqlbatchParser messageLen = do
  start <- parserPosition
  _headers <- allHeadersParser
  end <- parserPosition
  text <- Atto.take (messageLen - (end - start))
  pure (SQLBatchMessage (T.decodeUtf16LE text))

我们实际上忽略了头文件,只对SQL查询感兴趣。我们从流中抓取,并将其解码为UTF16 little-endian,这是TDS的指定文本格式。

一个例子是这样的,以十六进制编辑器的格式。

01 01 00 5C 00 00 01 00            . . . \ . . . .
16 00 00 00 12 00 00 00            . . . . . . . .
02 00 00 00 00 00 00 00            . . . . . . . .
00 01 00 00 00 00 0A 00            . . . . . . . .
73 00 65 00 6C 00 65 00            s . e . l . e .
63 00 74 00 20 00 27 00            c . t .   . ' .
66 00 6F 00 6F 00 27 00            f . o . o . ' .
20 00 61 00 73 00 20 00              . a . s .   .
27 00 62 00 61 00 72 00            ' . b . a . r .
27 00 0A 00 20 00 20 00            ' . . .   .   .
20 00 20 00 20 00 20 00              .   .   .   .
20 00 20 00                          .   .

或者用表格格式:

部分内容意义
类型01SQL批处理请求
状态01最后一个数据包
长度00 5C92
SPID00 000
数据包011
窗口000
标题16 00 00 00 12 00 00 00 02 00 00 00 00 00 00 00 00 01 00 00 00 00不重要
SQL0a 00 73 00 65 00 6c 00 65 00 63 00 74 00 20 00 27 00 66 00 6f 00 6f 00 27 00 20 00 61 00 73 00 20 00 27 00 62 00 61 00 72 00 27 00 0a 00 20 00 20 00 20 00 20 00 20 00 20 00 20 00 20 00 20 00 20 00 20 00 20 00 20 00 00"\nselect 'foo' as 'bar'\n "

其他解析器也遵循同样的模式。请记住,attoparsec中的所有内容都是增量的,所以我们不必担心边界和数据包被分散到多个块中。解析器被喂食,直到它被满足。

实施:渲染

碰巧的是,对于渲染对服务器的回复,实际上有三个服务器消息值得关注。发送登录接受、登录前确认和所谓的 "一般响应",这就是我们对返回查询结果感兴趣的目的。GeneralResponse 包括一个响应令牌的向量Vector ResponseToken 。但在本文中,我们只关注Row

data ResponseToken = ... | Row !(Vector Value)

具体来说,渲染一个响应令牌的实现是:

renderResponseToken :: ResponseToken -> Builder
renderResponseToken =
  \case
    ...
    Row vs -> word8 0xD1 <> foldMap renderValue (V.toList vs)

对于返回查询的行,我们有Value

data Value
  = TextValue !Text
  | BoolValue !Bool
  | DoubleValue !Double
  | Int32Value !Int32
  | Int16Value !Int16

这很容易呈现为一个构建器:

renderValue :: Value -> Builder
renderValue =
  \case
    TextValue t -> word16LE (fromIntegral (T.length t * 2)) <> byteString (T.encodeUtf16LE t)
    DoubleValue d -> doubleLE d
    BoolValue b -> word8 (if b then 1 else 0)
    Int16Value i -> int16LE i
    Int32Value i -> int32LE i

还记得Builder 的属性吗?它逐步建立数据结构。向它请求数据块的管道将请求下一个数据块,并将其写入套接字。我们稍后会看一下服务器的内存使用情况,并确认它在行数上的O(1)

实施:渡过消息

为了处理一个连接,我们有两个管道:

  1. 一个是提供传入的流,也称为源。
  2. 一个是提供流出的流,也称为汇。

我们只需要在一个循环中消耗传入的流并对消息进行调度。让我们用我们一直在使用的简单例子来展示一个更容易消化的版本。

#!/usr/bin/env stack
-- stack --resolver lts-12.12 script
{-# LANGUAGE OverloadedStrings #-}
import           Data.Conduit.Network
import qualified Data.ByteString as S
import qualified Data.Attoparsec.ByteString as P
import           Conduit
import           Data.Conduit.Attoparsec

main =
  runTCPServer
    (serverSettings 2019 "*")
    (\app -> do
       putStrLn "Someone connected!"
       runConduit (appSource app .| conduitParserEither parser .| handlerSink app))
  where
    handlerSink app = do
      mnext <- await
      case mnext of
        Nothing -> liftIO (putStrLn "Connection closed.")
        Just eithermessage ->
          case eithermessage of
            Left err -> liftIO (print err)
            Right (position, message) -> do
              liftIO (print message)
              liftIO (runConduit (yield "Thanks!\n" .| appSink app))
              handlerSink app
    parser = do
      len <- P.anyWord8
      bytes <- P.take (fromIntegral len)
      return bytes

下面是正在发生的事情:

  1. 我们运行一个服务器,然后立即开始一个管道,从套接字中消耗块状信息。
  2. 我们用管道将这些块送入attoparsec解析器,产生(Either ParseError (PositionRange, ByteString)) ,其中ByteString 是我们想要的解析的东西。
  3. 我们将其送入我们的handlerSink ,它一次消费这些解析结果。
  4. 如果await 产生了Nothing ,这就是流的结束。
  5. 如果eithermessageLeft ,那么我们就有一个解析错误。我们可能想在这里结束并打印错误。所以这就是我们所做的。
  6. 如果是Right ,那么我们就打印出信息,送回"Thanks" ,然后继续我们的汇流循环。

下面是行为的样子:

$ cat > writer.hs
main = putStr "\5Hello\5World"
chris@precision:~$ stack ghc -- writer.hs -o writer -v0
chris@precision:~$ ./writer | nc localhost 2019
Thanks!
Thanks!
C-c

服务器报告:

$ ./conduit-atto.hs
Someone connected!
"Hello"
"World"
Connection closed.

就像我们所期望的那样!假的SQL服务器的行为与此相同,但是使用了我们简单看过的数据包的解析器,以及我们也看过的渲染器。

值得花点时间来消化一下能够如此琐碎地处理二进制协议的成就。这要归功于attoparsec和 conduit提供的抽象概念。

测试机会

测试很容易,因为我们使用的是高级别抽象。conduit的处理代码与attoparsec的解析代码和渲染代码是解耦的。

  • 你可以在测试套件中提供一个字节流,这些字节应该来自客户端。我们在上面用我们的myparser 剖析器探讨了这个问题。
  • 你也可以将这些字节以不同的块的排列方式发送,以检查边界是否被正确处理。我们也通过向导管和attoparsec提供逐个字节的数据块来研究这个问题。
  • 你可以测试随机输入和输出。

为了测试随机输出,我们可以实现QuickCheck的Arbitrary的一个实例。

instance Test.QuickCheck.Arbitrary Value where
  arbitrary = oneof [text, int32, int16, double, bool]
    where
      text = (TextValue . T.pack) <$> Test.QuickCheck.arbitrary
      bool = (BoolValue) <$> Test.QuickCheck.arbitrary
      double = DoubleValue <$> Test.QuickCheck.arbitrary
      int32 = Int32Value <$> Test.QuickCheck.arbitrary
      int16 = Int16Value <$> Test.QuickCheck.arbitrary

的实例,为行列生成一个随机的Value

> Test.QuickCheck.generate (Test.QuickCheck.arbitrary :: Test.QuickCheck.Gen Value)
BoolValue True
> Test.QuickCheck.generate (Test.QuickCheck.arbitrary :: Test.QuickCheck.Gen Value)
Int16Value 10485
> Test.QuickCheck.generate (Test.QuickCheck.arbitrary :: Test.QuickCheck.Gen Value)
TextValue "2\130Fc\f\SI:r*^\228\185R|\239yk\246D0~\204Z\255d\138^P"

有了这个,我们就可以建立一个测试套件,生成一个值的向量,Vector Value ,运行我们的服务器,连接到它,提出请求,让服务器返回这个值的向量,然后检查ODBC库返回的行是否与我们希望服务器返回的一致。很简单!

探索内存的使用

让我们看一下这个服务器的内存使用情况。这是一个简单的服务器,它接受要产生的行数,并从一个CSV文件中读取这么多行。

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
import qualified Data.Map.Strict as M
import           Data.Map.Strict (Map)
import           Data.ByteString (ByteString)
import qualified Data.Conduit.List as CL
import           Data.CSV.Conduit
import qualified Data.Conduit.Binary as CB
import           Data.Conduit.Network
import qualified Data.Attoparsec.ByteString as P
import           Conduit
import           Data.Conduit.Attoparsec

main =
  runTCPServer
    (serverSettings 2019 "*")
    (\app -> do
       putStrLn "Someone connected!"
       runConduit
         (appSource app .| conduitParserEither parser .| handlerSink app))
  where
    handlerSink app = do
      mnext <- await
      case mnext of
        Nothing -> liftIO (putStrLn "Connection closed.")
        Just eithermessage ->
          case eithermessage of
            Left err -> liftIO (print err)
            Right (position, lineCount) -> do
              liftIO (print lineCount)
              liftIO
                (runConduitRes
                   (CB.sourceFile "fake-db.csv" .| intoCSV defCSVSettings .|
                    CL.mapMaybe
                      (M.lookup "Name" :: Map ByteString ByteString -> Maybe ByteString) .|
                    CL.map (<> "\n") .|
                    CL.isolate lineCount .|
                    appSink app))
              handlerSink app
    parser = do
      len <- P.anyWord8
      return (fromIntegral len * 1000)

在这里,它只是从输入中读出一个word8的值为 "n thousand"。然后我们以流的方式加载fake-db.csv ,将字节转换为CSV行,为每一行查找"Name" 字段,如果有的话,给每个名字加一个换行,然后isolate ,获取lineCount 结果。

让我们在启用运行时选项的情况下编译它:

stack ghc ./sql-dummy.hs --resolver lts-12.12 -- -rtsopts

并用统计输出来运行它。下面是我们要求1000行时发生的情况。

$ ./sql-dummy +RTS -s
Someone connected!
1000
       9,149,904 bytes allocated in the heap
          36,848 bytes copied during GC
         120,112 bytes maximum residency (2 sample(s))
          35,680 bytes maximum slop
               2 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0         7 colls,     0 par    0.000s   0.000s     0.0001s    0.0001s
  Gen  1         2 colls,     0 par    0.000s   0.001s     0.0004s    0.0006s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.022s  (  4.387s elapsed)
  GC      time    0.001s  (  0.001s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    0.023s  (  4.388s elapsed)

  %GC     time       2.4%  (0.0% elapsed)

  Alloc rate    422,940,926 bytes per MUT second

  Productivity  96.2% of total user, 100.0% of total elapsed

这是我们要求2,000行时的情况,通过:

$ printf '\x02' | nc 127.0.0.1 2019

我们得到:

$ ./sql-dummy +RTS -s
Someone connected!
2000
      18,125,240 bytes allocated in the heap
          43,656 bytes copied during GC
         120,112 bytes maximum residency (2 sample(s))
          35,680 bytes maximum slop
               2 MB total memory in use (0 MB lost due to fragmentation)

                                     Tot time (elapsed)  Avg pause  Max pause
  Gen  0        16 colls,     0 par    0.000s   0.001s     0.0000s    0.0001s
  Gen  1         2 colls,     0 par    0.000s   0.001s     0.0003s    0.0006s

  INIT    time    0.000s  (  0.000s elapsed)
  MUT     time    0.029s  (  6.247s elapsed)
  GC      time    0.001s  (  0.001s elapsed)
  EXIT    time    0.000s  (  0.000s elapsed)
  Total   time    0.030s  (  6.248s elapsed)

  %GC     time       2.5%  (0.0% elapsed)

  Alloc rate    628,824,590 bytes per MUT second

  Productivity  96.6% of total user, 100.0% of total elapsed

以下是不同之处:

  • 我们做了两倍的垃圾回收,正如你所期望的那样。
  • 在整个程序运行过程中,后一个程序分配的总量是前者的两倍。
  • 但是在这两种情况下,最大驻留量(120KB)保持不变。这就是在特定时间内所占用的内存的数量。

这意味着,我们的总驻留内存使用量从未超过CSV中最大行的大小。对于一个需要提高内存效率和洗刷大量数据的服务器来说,这是很完美的。

绕过来

让我们回头看看我们最初的目标:

  1. 制作一个假的SQL服务器。我们看了TDS协议和一些如何工作的例子。
  2. 要有干净、简单的代码库。我们看了一些很好的库,它们可以很好地连接在一起以实现这一目标。
  3. 要有一个流式结构,以便内存的使用是恒定的。我们看了一个真实的代码样本,它是如何工作的以及我们看到的结果。

开发经验很顺利,因为我们能够专注于重要的事情,比如手头的协议,而不是边界、缓冲区大小、规模问题或内存问题。