本文译自 What is a NoSQL Database? Learn By Writing One In Python.python
完整的示例代码已经放到了 GitHub 上, 请 点击这里, 这仅是一个极简的 demo, 旨在动手了解概念.git
若是对译文有任何的意见或建议,欢迎 提 issue 讨论, 批评指正.github
后续若有更新,可见 博客 .算法
NoSQL 这个词在近些年正变得随处可见. 可是到底 "NoSQL" 指的是什么? 它是如何而且为何这么有用? 在本文, 咱们将会经过纯 Python (我比较喜欢叫它, "轻结构化的伪代码") 写一个 NoSQL 数据库来回答这些问题.sql
不少状况下, SQL 已经成为 "数据库" (database) 的一个同义词. 实际上, SQL 是 Strctured Query Language 的首字母缩写, 而并不是指数据库技术自己. 更确切地说, 它所指的是从 RDBMS (关系型数据库管理系统, Relational Database Management System ) 中检索数据的一门语言. MySQL, MS SQL Server 和 Oracle 都属于 RDBMS 的其中一员.数据库
RDBMS 中的 R, 即 "Relational" (有关系,关联的), 是其中内容最丰富的部分. 数据经过 表 (table) 进行组织, 每张表都是一些由 类型 (type) 相关联的 列 (column) 构成. 全部表, 列及其类的类型被称为数据库的 schema (架构或模式). schema 经过每张表的描述信息完整刻画了数据库的结构. 好比, 一张叫作 Car
的表可能有如下一些列:服务器
Make: a string网络
Model: a string数据结构
Year: a four-digit number; alternatively, a date架构
Color: a string
VIN(Vehicle Identification Number): a string
在一张表中, 每一个单一的条目叫作一 行 (row), 或者一条 记录 (record). 为了区分每条记录, 一般会定义一个 主键 (primary key). 表中的 主键 是其中一列 , 它可以惟一标识每一行. 在表 Car
中, VIN 是一个自然的主键选择, 由于它可以保证每辆车具备惟一的标识. 两个不一样的行可能会在 Make, Model, Year 和 Color 列上有相同的值, 可是对于不一样的车而言, 确定会有不一样的 VIN. 反之, 只要两行拥有同一个 VIN, 咱们没必要去检查其余列就能够认为这两行指的的就是同一辆车.
SQL 可以让咱们经过对数据库进行 query (查询) 来获取有用的信息. 查询 简单来讲, 查询就是用一个结构化语言向 RDBMS 提问, 并将其返回的行解释为问题的答案. 假设数据库表示了美国全部的注册车辆, 为了获取 全部的 记录, 咱们能够经过在数据库上进行以下的 SQL 查询 :
SELECT Make, Model FROM Car;
将 SQL 大体翻译成中文:
"SELECT": "向我展现"
"Make, Model": "Make 和 Model 的值"
"FROM Car": "对表 Car 中的每一行"
也就是, "向我展现表 Car 每一行中 Make 和 Model 的值". 执行查询后, 咱们将会获得一些查询的结果, 其中每一个都是 Make 和 Model. 若是咱们仅关心在 1994 年注册的车的颜色, 那么能够:
SELECT Color FROM Car WHERE Year = 1994;
此时, 咱们会获得一个相似以下的列表:
Black Red Red White Blue Black White Yellow
最后, 咱们能够经过使用表的 (primary key) 主键 , 这里就是 VIN 来指定查询一辆车:
SELECT * FROM Car WHERE VIN = '2134AFGER245267'
上面这条查询语句会返回所指定车辆的属性信息.
主键被定义为惟一不可重复的. 也就是说, 带有某一指定 VIN 的车辆在表中至多只能出现一次. 这一点很是重要,为何? 来看一个例子:
假设咱们正在经营一个汽车修理的业务. 除了其余一些必要的事情, 咱们还须要追踪一辆车的服务历史, 即在该辆车上全部的修整记录. 那么咱们可能会建立包含如下一些列的 ServiceHistory
表:
VIN | Make | Model | Year | Color | Service Performed | Mechanic | Price | Date
这样, 每次当车辆维修之后, 咱们就在表中添加新的一行, 并写入该次服务咱们作了一些什么事情, 是哪位维修工, 花费多少和服务时间等.
可是等一下, 咱们都知道,对于同一辆车而言,全部车辆自身信息有关的列是不变的。 也就是说,若是把个人 Black 2014 Lexus RX 350 修整 10 次的话, 那么即便 Make, Model, Year 和 Color 这些信息并不会改变,每一次仍然重复记录了这些信息. 与无效的重复记录相比, 一个更合理的作法是对此类信息只存储一次, 并在有须要的时候进行查询。
那么该怎么作呢? 咱们能够建立第二张表: Vehicle
, 它有以下一些列:
VIN | Make | Model | Year | Color
这样一来, 对于 ServiceHistory
表, 咱们能够精简为以下一些列:
VIN | Service Performed | Mechanic | Price | Date
你可能会问,为何 VIN 会在两张表中同时出现? 由于咱们须要有一个方式来确认在 ServiceHistory
表的 这 辆车指的就是 Vehicle
表中的 那 辆车, 也就是须要确认两张表中的两条记录所表示的是同一辆车。 这样的话,咱们仅须要为每辆车的自身信息存储一次便可. 每次当车辆过来维修的时候, 咱们就在 ServiceHistory
表中建立新的一行, 而没必要在 Vehicle
表中添加新的记录。 毕竟, 它们指的是同一辆车。
咱们能够经过 SQL 查询语句来展开 Vehicle
与 ServiceHistory
两张表中包含的隐式关系:
SELECT Vehicle.Model, Vehicle.Year FROM Vehicle, ServiceHistory WHERE Vehicle.VIN = ServiceHistory.VIN AND ServiceHistory.Price > 75.00;
该查询旨在查找维修费用大于 $75.00 的全部车辆的 Model 和 Year. 注意到咱们是经过匹配 Vehicle
与 ServiceHistory
表中的 VIN 值来筛选知足条件的记录. 返回的将是两张表中符合条件的一些记录, 而 "Vehicle.Model" 与 "Vehicle.Year" , 表示咱们只想要 Vehicle
表中的这两列.
若是咱们的数据库没有 索引 (indexes) (正确的应该是 indices), 上面的查询就须要执行 表扫描 (table scan) 来定位匹配查询要求的行。 table scan 是按照顺序对表中的每一行进行依次检查, 而这一般会很是的慢。 实际上, table scan 其实是全部查询中最慢的。
能够经过对列加索引来避免扫描表。 咱们能够把索引看作一种数据结构, 它可以经过预排序让咱们在被索引的列上快速地找到一个指定的值 (或指定范围内的一些值). 也就是说, 若是咱们在 Price 列上有一个索引, 那么就不须要一行一行地对整个表进行扫描来判断其价格是否大于 75.00
, 而是只须要使用包含在索引中的信息 “跳” 到第一个价格高于 75.00
的那一行, 并返回随后的每一行(因为索引是有序的, 所以这些行的价格至少是 75.00
)。
当应对大量的数据时, 索引是提升查询速度不可或缺的一个工具。固然, 跟全部的事情同样,有得必有失, 使用索引会致使一些额外的消耗: 索引的数据结构会消耗内存,而这些内存本可用于数据库中存储数据。这就须要咱们权衡其利弊,寻求一个折中的办法, 可是为常常查询的列加索引是 很是 常见的作法。
得益于数据库可以检查一张表的 schema (描述了每列包含了什么类型的数据), 像索引这样的高级特性才可以实现, 而且可以基于数据作出一个合理的决策。 也就是说, 对于一个数据库而言, 一张表实际上是一个 “黑盒” (或者说透明的盒子) 的反义词?
当咱们谈到 NoSQL 数据库的时候要紧紧记住这一点。 当涉及 query 不一样类型数据库引擎的能力时, 这也是其中很是重要的一部分。
咱们已经知道, 一张表的 schema , 描述了列的名字及其所包含数据的类型。它还包括了其余一些信息, 好比哪些列能够为空, 哪些列不容许有重复值, 以及其余对表中列的全部限制信息。 在任意时刻一张表只能有一个 schema, 而且 表中的全部行必须遵照 schema 的规定 。
这是一个很是重要的约束条件。 假设你有一张数据库的表, 里面有数以百万计的消费者信息。 你的销售团队想要添加额外的一些信息 (好比, 用户的年龄), 以期提升他们邮件营销算法的准确度。 这就须要来 alter (更改) 现有的表 -- 添加新的一列。 咱们还须要决定是否表中的每一行都要求该列必须有一个值。 一般状况下, 让一个列有值是十分有道理的, 可是这么作的话可能会须要一些咱们没法轻易得到的信息(好比数据库中每一个用户的年龄)。所以在这个层面上,也须要有些权衡之策。
此外,对一个大型数据库作一些改变一般并非一件小事。为了以防出现错误,有一个回滚方案很是重要。但即便是如此,一旦当 schema 作出改变后,咱们也并不老是可以撤销这些变更。 schema 的维护多是 DBA 工做中最困难的部分之一。
在 “NoSQL” 这个词存在前, 像 memcached
这样的 键/值 数据存储 (Key/Value Data Stores) 无须 table schema 也可提供数据存储的功能。 实际上, 在 K/V 存储时, 根本没有 "表 (table)" 的概念。 只有 键 (keys) 与 值 (values) . 若是键值存储听起来比较熟悉的话, 那多是由于这个概念的构建原则与 Python 的 dict
与 set
相一致: 使用 hash table (哈希表) 来提供基于键的快速数据查询。 一个基于 Python 的最原始的 NoSQL 数据库, 简单来讲就是一个大的字典 (dictionary) .
为了理解它的工做原理,亲自动手写一个吧! 首先来看一下一些简单的设计想法:
一个 Python 的 dict
做为主要的数据存储
仅支持 string 类型做为键 (key)
支持存储 integer, string 和 list
一个使用 ASCLL string 的简单 TCP/IP 服务器用来传递消息
一些像 INCREMENT
, DELETE
, APPEND
和 STATS
这样的高级命令 (command)
有一个基于 ASCII 的 TCP/IP 接口的数据存储有一个好处, 那就是咱们使用简单的 telnet
程序便可与服务器进行交互, 并不须要特殊的客户端 (尽管这是一个很是好的练习而且只须要 15 行代码便可完成)。
对于咱们发送到服务器及其它的返回信息,咱们须要一个 “有线格式”。下面是一个简单的说明:
PUT
参数: Key, Value
目的: 向数据库中插入一条新的条目 (entry)
GET
参数: Key
目的: 从数据库中检索一个已存储的值
PUTLIST
参数: Key, Value
目的: 向数据库中插入一个新的列表条目
APPEND
参数: Key, Value
目的: 向数据库中一个已有的列表添加一个新的元素
INCREMENT
参数: key
目的: 增加数据库的中一个整型值
DELETE
参数: Key
目的: 从数据库中删除一个条目
STATS
参数: 无 (N/A)
目的: 请求每一个执行命令的 成功/失败 的统计信息
如今咱们来定义消息的自身结构。
一条 请求消息 (Request Message) 包含了一个命令(command),一个键 (key), 一个值 (value), 一个值的类型(type). 后三个取决于消息类型,是可选项, 非必须。;
被用做是分隔符。即便并无包含上述可选项, 可是在消息中仍然必须有三个 ;
字符。
COMMAND; [KEY]; [VALUE]; [VALUE TYPE]
COMMAND 是上面列表中的命令之一
KEY 是一个能够用做数据库 key 的 string (可选)
VALUE 是数据库中的一个 integer, list 或 string (可选)
list 能够被表示为一个用逗号分隔的一串 string, 好比说, "red, green, blue"
VALUE TYPE 描述了 VALUE 应该被解释为何类型
可能的类型值有:INT, STRING, LIST
"PUT; foo; 1; INT"
"GET; foo;;"
"PUTLIST; bar; a,b,c ; LIST"
"APPEND; bar; d; STRING"
"GETLIST; bar; ;"
STATS; ;;
INCREMENT; foo;;
DELETE; foo;;
一个 响应消息 (Reponse Message) 包含了两个部分, 经过 ;
进行分隔。第一个部分老是 True|False
, 它取决于所执行的命令是否成功。 第二个部分是命令消息 (command message), 当出现错误时,便会显示错误信息。对于那些执行成功的命令,若是咱们不想要默认的返回值(好比 PUT
), 就会出现成功的信息。 若是咱们返回成功命令的值 (好比 GET
), 那么第二个部分就会是自身值。
True; Key [foo] set to [1]
True; 1
True; Key [bar] set to [['a', 'b', 'c']]
True; Key [bar] had value [d] appended
True; ['a', 'b', 'c', 'd']
True; {'PUTLIST': {'success': 1, 'error': 0}, 'STATS': {'success': 0, 'error': 0}, 'INCREMENT': {'success': 0, 'error': 0}, 'GET': {'success': 0, 'error': 0}, 'PUT': {'success': 0, 'error': 0}, 'GETLIST': {'success': 1, 'error': 0}, 'APPEND': {'success': 1, 'error': 0}, 'DELETE': {'success': 0, 'error': 0}}
我将会以块状摘要的形式来展现所有代码。 整个代码不过 180 行,读起来也不会花费很长时间。
下面是咱们服务器所需的一些样板代码:
"""NoSQL database written in Python""" # Standard library imports import socket HOST = 'localhost' PORT = 50505 SOCKET = socket.socket(socket.AF_INET, socket.SOCK_STREAM) STATS = { 'PUT': {'success': 0, 'error': 0}, 'GET': {'success': 0, 'error': 0}, 'GETLIST': {'success': 0, 'error': 0}, 'PUTLIST': {'success': 0, 'error': 0}, 'INCREMENT': {'success': 0, 'error': 0}, 'APPEND': {'success': 0, 'error': 0}, 'DELETE': {'success': 0, 'error': 0}, 'STATS': {'success': 0, 'error': 0}, }
很容易看到, 上面的只是一个包的导入和一些数据的初始化。
接下来我会跳过一些代码, 以便可以继续展现上面准备部分剩余的代码。 注意它涉及到了一些尚不存在的一些函数, 不过不要紧, 咱们会在后面涉及。 在完整版(将会呈如今最后)中, 全部内容都会被有序编排。 这里是剩余的安装代码:
COMMAND_HANDERS = { 'PUT': handle_put, 'GET': handle_get, 'GETLIST': handle_getlist, 'PUTLIST': handle_putlist, 'INCREMENT': handle_increment, 'APPEND': handle_append, 'DELETE': handle_delete, 'STATS': handle_stats, } DATA = {} def main(): """Main entry point for script""" SOCKET.bind(HOST, PORT) SOCKET.listen(1) while 1: connection, address = SOCKET.accept() print('New connection from [{}]'.format(address)) data = connection.recv(4096).decode() command, key, value = parse_message(data) if command == 'STATS': response = handle_stats() elif command in ('GET', 'GETLIST', 'INCREMENT', 'DELETE'): response = COMMAND_HANDERS[command](key) elif command in ( 'PUT', 'PUTLIST', 'APPEND', ): response = COMMAND_HANDERS[command](key, value) else: response = (False, 'Unknown command type {}'.format(command)) update_stats(command, response[0]) connection.sandall('{};{}'.format(response[0], response[1])) connection.close() if __name__ == '__main__': main()
咱们建立了 COMMAND_HANDLERS
, 它常被称为是一个 查找表 (look-up table) . COMMAND_HANDLERS
的工做是将命令与用于处理该命令的函数进行关联起来。 好比说, 若是咱们收到一个 GET
命令, COMMAND_HANDLERS[command](key)
就等同于说 handle_get(key)
. 记住,在 Python 中, 函数能够被认为是一个值,而且能够像其余任何值同样被存储在一个 dict
中。
在上面的代码中, 虽然有些命令请求的参数相同,可是我仍决定分开处理每一个命令。 尽管能够简单粗暴地强制全部的 handle_
函数接受一个 key
和一个 value
, 可是我但愿这些处理函数条理可以更加有条理, 更加容易测试,同时减小出现错误的可能性。
注意 socket 相关的代码已经是十分极简。 虽然整个服务器基于 TCP/IP 通讯, 可是并无太多底层的网络交互代码。
最后还须须要注意的一小点: DATA
字典, 由于这个点并不十分重要, 于是你极可能会遗漏它。 DATA
就是实际用来存储的 key-value pair, 正是它们实际构成了咱们的数据库。
下面来看一些 命令解析器 (command parser) , 它负责解释接收到的消息:
def parse_message(data): """Return a tuple containing the command, the key, and (optionally) the value cast to the appropriate type.""" command, key, value, value_type = data.strip().split(';') if value_type: if value_type == 'LIST': value = value.split(',') elif value_type == 'INT': value = int(value) else: value = str(value) else: value = None return command, key, value
这里咱们能够看到发生了类型转换 (type conversion). 若是但愿值是一个 list, 咱们能够经过对 string 调用 str.split(',')
来获得咱们想要的值。 对于 int
, 咱们能够简单地使用参数为 string 的 int()
便可。 对于字符串与 str()
也是一样的道理。
下面是命令处理器 (command handler) 的代码. 它们都十分直观,易于理解。 注意到虽然有不少的错误检查, 可是也并非面面俱到, 十分庞杂。 在你阅读的过程当中,若是发现有任何错误请移步 这里 进行讨论.
def update_stats(command, success): """Update the STATS dict with info about if executing *command* was a *success*""" if success: STATS[command]['success'] += 1 else: STATS[command]['error'] += 1 def handle_put(key, value): """Return a tuple containing True and the message to send back to the client.""" DATA[key] = value return (True, 'key [{}] set to [{}]'.format(key, value)) def handle_get(key): """Return a tuple containing True if the key exists and the message to send back to the client""" if key not in DATA: return (False, 'Error: Key [{}] not found'.format(key)) else: return (True, DATA[key]) def handle_putlist(key, value): """Return a tuple containing True if the command succeeded and the message to send back to the client.""" return handle_put(key, value) def handle_putlist(key, value): """Return a tuple containing True if the command succeeded and the message to send back to the client""" return handle_put(key, value) def handle_getlist(key): """Return a tuple containing True if the key contained a list and the message to send back to the client.""" return_value = exists, value = handle_get(key) if not exists: return return_value elif not isinstance(value, list): return (False, 'ERROR: Key [{}] contains non-list value ([{}])'.format( key, value)) else: return return_value def handle_increment(key): """Return a tuple containing True if the key's value could be incremented and the message to send back to the client.""" return_value = exists, value = handle_get(key) if not exists: return return_value elif not isinstance(list_value, list): return (False, 'ERROR: Key [{}] contains non-list value ([{}])'.format( key, value)) else: DATA[key].append(value) return (True, 'Key [{}] had value [{}] appended'.format(key, value)) def handle_delete(key): """Return a tuple containing True if the key could be deleted and the message to send back to the client.""" if key not in DATA: return ( False, 'ERROR: Key [{}] not found and could not be deleted.'.format(key)) else: del DATA[key] def handle_stats(): """Return a tuple containing True and the contents of the STATS dict.""" return (True, str(STATS))
有两点须要注意: 多重赋值 (multiple assignment) 和代码重用. 有些函数仅仅是为了更加有逻辑性而对已有函数的简单包装而已, 好比 handle_get
和 handle_getlist
. 因为咱们有时仅仅是须要一个已有函数的返回值,而其余时候却须要检查该函数到底返回了什么内容, 这时候就会使用 多重赋值 。
来看一下 handle_append
. 若是咱们尝试调用 handle_get
可是 key 并不存在时, 那么咱们简单地返回 handle_get
所返回的内容。 此外, 咱们还但愿可以将 handle_get
返回的 tuple 做为一个单独的返回值进行引用。 那么当 key 不存在的时候, 咱们就能够简单地使用 return return_value
.
若是它 确实存在 , 那么咱们须要检查该返回值。而且, 咱们也但愿可以将 handle_get
的返回值做为单独的变量进行引用。 为了可以处理上述两种状况,同时考虑须要分开处理结果的情形,咱们使用了多重赋值。 如此一来, 就没必要书写多行代码, 同时可以保持代码清晰。 return_value = exists, list_value = handle_get(key)
可以显式地代表咱们将要以致少两种不一样的方式引用 handle_get
的返回值。
上面的程序显然并不是一个 RDBMS, 但却绝对称得上是一个 NoSQL 数据库。它如此易于建立的缘由是咱们并无任何与 数据 (data) 的实际交互。 咱们只是作了极简的类型检查,存储用户所发送的任何内容。 若是须要存储更加结构化的数据, 咱们可能须要针对数据库建立一个 schema 用于存储和检索数据。
既然 NoSQL 数据库更容易写, 更容易维护,更容易实现, 那么咱们为何不是只使用 mongoDB 就行了? 固然是有缘由的, 仍是那句话,有得必有失, 咱们须要在 NoSQL 数据库所提供的数据灵活性 (data flexibility) 基础上权衡数据库的可搜索性 (searchability).
假如咱们上面的 NoSQL 数据库来存储早前的 Car 数据。 那么咱们可能会使用 VIN 做为 key, 使用一个列表做为每列的值, 也就是说, 2134AFGER245267 = ['Lexus', 'RX350', 2013, Black]
. 固然了, 咱们已经丢掉了列表中每一个索引的 涵义 (meaning) . 咱们只须要知道在某个地方索引 1 存储了汽车的 Model , 索引 2 存储了 Year.
糟糕的事情来了, 当咱们想要执行先前的查询语句时会发生什么? 找到 1994 年全部车的颜色将会变得噩梦通常。 咱们必须遍历 DATA
中的 每个值 来确认这个值是否存储了 car 数据亦或根本是其余不相关的数据, 好比说检查索引 2, 看索引 2 的值是否等于 1994,接着再继续取索引 3 的值. 这比 table scan 还要糟糕,由于它不只要扫描每一行数据,还须要应用一些复杂的规则来回答查询。
NoSQL 数据库的做者固然也意识到了这些问题,(鉴于查询是一个很是有用的 feature) 他们也想出了一些方法来使得查询变得不那么 “高不可攀”。一个方法是结构化所使用的数据,好比 JSON, 容许引用其余行来表示关系。 同时, 大部分 NoSQL 数据库都有名字空间 (namespace) 的概念, 单一类型的数据能够被存储在数据库中该类型所独有的 "section" 中,这使得查询引擎可以利用所要查询数据的 "shape" 信息。
固然了,尽管为了加强可查询性已经存在 (而且实现了)了一些更加复杂的方法, 可是在存储更少许的 schema 与加强可查询性之间作出妥协始终是一个不可逃避的问题。 本例中咱们的数据库仅支持经过 key 进行查询。 若是咱们须要支持更加丰富的查询, 那么事情就会变得复杂的多了。
至此, 但愿 "NoSQL" 这个概念已然十分清晰。 咱们学习了一点 SQL, 而且了解了 RDBMS 是如何工做的。 咱们看到了如何从一个 RDBMS 中检索数据 (使用 SQL 查询 (query)). 经过搭建了一个玩具级别的 NoSQL 数据库, 了解了在可查询性与简洁性之间面临的一些问题, 还讨论了一些数据库做者应对这些问题时所采用的一些方法。
即使是简单的 key-value 存储, 关于数据库的知识也是浩瀚无穷。虽然咱们仅仅是探讨了其中的星星点点, 可是仍然但愿你已经了解了 NoSQL 到底指的是什么, 它是如何工做的, 何时用比较好。若是您想要分享一些不错的想法, 欢迎 讨论.