本文已参与「新人创作礼」活动,一起开启掘金创作之路。
本文适合于旧版本的MongoDB,新版的MongoDB已经有现成的pu/sub队列机制了。本文从作者自己的CSDN搬过来的。-编辑于2022年4月19日
Mongo是很有特色的基于文档(Document)的NoSQL,这是一个通用性的数据库。也就是说它支持很多功能,很多业务场景。有的小公司如果单种业务少,但功能需要多,那么后台用Mongo再好不过,因为只需要一个后台,数据库管理和开发,数据分析,报表,未来维护都节约成本。这只是Mongo被业界广泛应用的原因之一。 这篇博客介绍Mongo的队列消息的功能,正好应用了Mongo自身的不可更改的保证时间先后的集合Capped collection。
通篇借用了英文原文/1/(文章末尾), 原文是用Python实现的,本文全部用Java实现,并发布在码云,供爱好者下载尝试。/2/(文章末尾).
Capped Collections
Capped Collection(Capped 集合)是高性能循环重复的队列,说他循环,也就是说队列满了以后,又重头写入,原来的就删去了。Capped collection有以下特性:
-
他们记住文档插入的先后顺序,也就是谁先进入队列,就先在磁盘记录在案。
-
因为循环使用,最旧的文档当队列满了,就会被删去,腾出空间,给新的消息。
capped collections 也有以下的缺点:
- 他们的容量是有一定大小的。
- 他们是不能被分片的。
- 任何在Capped Collection写入文档不能使Capped collection大小增长。 (也就是说,不是所有 push 或 $pushAll 会工作)
- 开发人员不可以从集合中用.remove() 来删除文档 (document)。
建立一个Capped Collection,我们可以用以下方式:(译者注,原文全部用Python语言,译者用Java根据原代码用Java重写了。文章末尾/2/的链接)
db.create_collection(
'capped_collection',
capped=True,
size=size_in_bytes, # required
max=max_number_of_docs, # optional
autoIndexId=False) # optional
在以上例子, 建立了一个容量是size_in_bytes 的Capped collection, 它不可以超过 max_number_of_docs 个文档,它也不建立基于 _id 字段的索引. 根据先前说的, capped collection 记住文档的先后顺序. 如果你使用不带排序的find(), 或者排序参数为 ('natural, -1) 会按插入时间的倒序来输出结果。由于插入的记录在磁盘也是按插入集合的顺序的, 那么基于Capped Collection的查询是非常快的. 让我们验证一下, 先建立两个collections, 一个是capped,另一个不是, 然后插入少量的文档:
size = 100000
#Create the collection
db.create_collection(
'capped_collection',
capped=True,
size=2**20,
autoIndexId=False)
db.create_collection(
'uncapped_collection',
autoIndexId=False)
# Insert small documents into both
for x in range(size):
db.capped_collection.insert({'x':x}, manipulate=False)
db.uncapped_collection.insert({'x':x},manipulate=False)
#Go ahead and index the 'x' field in the uncapped collection
db.uncapped_collection.ensure_index('x')
我们可以通过对于每一个集合执行find() 来看结果. 对于这个, 原文作者使用 IPython, IPyMongo, 和使用特效函数 %timeit:
In [72] (test): %timeit x=list(db.capped_collection.find())
1000 loops, best of 3: 708 us per loop
In [73] (test): %timeit x=list(db.uncapped_collection.find().sort('x'))
1000 loops, best of 3: 912 us per loop
我们得到少许的性能提升,这是好的,但没有大幅度性能提升。 最令人感兴趣的是Capped Collection支持tailable cursors. (译者注:类似Linux tail 命令)。
Tailable cursors
如果你对Capped collection查询,你可以使用一个特别的命令find()对Capped collection查询,他总是能跟踪并返回Capped collection的最后一个文档, 这有点像Unix tail -f 命令. 让我们看看 在Capped Collection上'普通' 的cursor 和 'tailable' cursor 有什么不同. 首先, '普通'的Cursor
[76] (test): cur = db.capped_collection.find()
In [77] (test): cur.next()
Out[77]: {u'x': 0}
In [78] (test): cur.next()
Out[78]: {u'x': 1}
In [79] (test): db.capped_collection.insert({'y': 1})
Out[79]: ObjectId('515f205cfb72f0385c3c2414')
In [80] (test): list(cur)
Out[80]:
[{u'x': 2},
...
{u'x': 99}]
我们插入的文档 {'y': 1} 没有在结果显示,因为插入在我们查询并逐个显示的命令的后面, 现在我们可以试验一下 tailable cursor:
In [81] (test): cur = db.capped_collection.find(tailable=True)
In [82] (test): cur.next()
Out[82]: {u'x': 1}
In [83] (test): cur.next()
Out[83]: {u'x': 2}
In [84] (test): db.capped_collection.insert({'y': 2})
Out[84]: ObjectId('515f20ddfb72f0385c3c2415')
In [85] (test): list(cur)
Out[85]:
[{u'x': 3},
...
{u'x': 99},
{u'_id': ObjectId('515f205cfb72f0385c3c2414'), u'y': 1},
{u'_id': ObjectId('515f20ddfb72f0385c3c2415'), u'y': 2}]
现在我们看到两个"y" 的文档,一个是先前创建的,一个是在查询后逐个显示后插入的。
等待数据
从一个方面说, tailable cursors非常好用,当我们通过find命令,逐个显示文档时,它显示后续插入的新文档, 但一个真正的 pub/sub 系统需要低延时. 如果是采用Poll 的方法,那么我们会碰到以下两点中的一点:
- 不断持续的Poll, 消耗计算机资源
- 间断性的Poll, 增加延时
Tailable cursors 有另外一个特性,你可以同时避免上述两个问题: await_data 选择命令符. 这个命令符告诉MongoDB 的tailable cursor,当这个指令执行时当发现没有返回值,再等一,两秒,看是否有后续的文档插入。在 PyMongo, 设置这个命令符非常简单:
cur = db.capped_collection.find(
tailable=True,
await_data=True)
实现Pub/Sub
好了,现在我们有capped collection, 和带有awaiting data 的tailable cursors, 怎样打造一个 pub/sub 系统? 基本思路是:
- 我们创建一个中等大小的Capped Collection (比如 32kB)。
- 发布一个消息: { 'k': topic, 'data': data } 插入新建的Capped collection一个文档。
- 通过使用 tailable query 来订阅这个集合 (Capped Collection), 可以用regular expression来过滤所有的消息。
这个订阅查询如以下显示:
def get_cursor(collection, topic_re, await_data=True):
options = { 'tailable': True }
if await_data:
options['await_data'] = True
cur = collection.find(
{ 'k': topic_re },
**options)
cur = cur.hint([('$natural', 1)]) # ensure we don't use any indexes
return cur
当我们有 get_cursor 函数, 我们可以执行以下的查询:
import re, time
while True:
cur = get_cursor(
db.capped_collection,
re.compile('^foo'),
await_data=True)
for msg in cur:
do_something(msg)
time.sleep(0.1)
当然, 上面打造的系统有以下问题:
在我们达到最后一个文档时,我们先访问了所有以前的消息。 当 cursor 达到最后并过了await_data时,Cursor必须回到集合的最前面。 我们通过对每一个文档/消息加一个序列号来解决以上问题。
序列号
"请等一下," 我想你要说, "MongoDB 没有自增长字段,类似的在MySQL里有的! 我们怎能产生序列号呢?" 答案是 find_and_modify() 命令, 并同时结合着命令符 inc 结合的命令来得到新的序列号. 代码其实非常短:
class Sequence(object):
def __init__(self, db, name='mongotools.sequence'):
self._db = db
self._name = name
def cur(self, name):
doc = self._db[self._name].find_one({'_id': name})
if doc is None: return 0
return doc['value']
def next(self, sname, inc=1):
doc = self._db[self._name].find_and_modify(
query={'_id': sname},
update={'$inc': { 'value': inc } },
upsert=True,
new=True)
return doc['value']
当我们可以产生序列号时, 我们发布(publish或pub)时,可以在每一个消息/文档上加一个序列号:
def pub(collection, sequence, key, data=None):
doc = dict(
ts=sequence.next(collection.name),
k=key,
data=data)
collection.insert(doc, manipulate=False)
当我们订阅(Subscriber / Sub)时,订阅的代码可能需要长一点:
def get_cursor(collection, topic_re, last_id=-1, await_data=True):
options = { 'tailable': True }
spec = {
'ts': { '$gt': last_id }, # only new messages
'k': topic_re }
if await_data:
options['await_data'] = True
cur = collection.find(spec, **options)
cur = cur.hint([('$natural', 1)]) # ensure we don't use any indexes
return cur
我们的主循环必须跟踪所有的序列号:
import re, time
last_id = -1
while True:
cur = get_cursor(
db.capped_collection,
re.compile('^foo'),
await_data=True)
for msg in cur:
last_id = msg['ts']
do_something(msg)
time.sleep(0.1)
现在,我们还有一个小小的改善,利用集合最后一个文档的 ts 字段,用它来初始化 last_id值:
last_id = -1
cur = db.capped_collection.find().sort([('$natural', -1)])
for msg in cur:
last_id = msg['ts']
break
我们在打造Pub/Sub中改善优化了好几步, 但我们任然有一个缺陷,我们必须在初始启动中,浏览所有的Capped collection,我们能改正吗? 答案是能, 但也需要特殊手段。
现在, 令人质疑的神奇...
大家会奇怪为什么用 ts 字段来代表序列号. 目前的文档诠释的不清楚,其实有一个可以大幅度提高最初启动时扫描整个集合文档的选择项命令符 oplog_replay. 从这个命令符的名字就可以看出, 它是为重演"oplog"而生, 这个令人神奇的命令原本是为了让使用capped collection时, 使内部复制的机制非常快. oplog 用 ts 字段显示某一个操作的时间戳, 同时 oplog_replay 选择项要求在查询里使用ts字段 .
现在我们知道 oplog_replay 并不是给我们开发者来用的,所以没有直接用在PyMongo的驱动器里,但我们可以用一些小伎俩:
from pymongo.cursor import _QUERY_OPTIONS
def get_cursor(collection, topic_re, last_id=-1, await_data=True):
options = { 'tailable': True }
spec = {
'ts': { '$gt': last_id }, # only new messages
'k': topic_re }
if await_data:
options['await_data'] = True
cur = collection.find(spec, **options)
cur = cur.hint([('$natural', 1)]) # ensure we don't use any indexes
if await:
cur = cur.add_option(_QUERY_OPTIONS['oplog_replay'])
return cur
(耶, 我知道import下划线的软件包是不好的习惯,但这比我们直接用 oplog_replay_option=8来得好一点点,。。。。)
性能
pubsub 系统用capped collections. 如果你自己想尝试一下,源代码在Github in 的 MongoTools 项目里.(译者注:译者根据Python源代码而来的Java 版本,在码云文件版本管理系统中,国内网址,速度快,git.oschina.net/sggzs_admin…, 性能大概一天普通电脑,是每秒1100个消息,延时是2.5秒。 在小型多功能应用中,绰绰有余。