Sentry的核心技术之一是SQL,特别是PostgreSQL,这已经不是什么秘密。我们非常提倡简单性,而Postgres就是这样一个工具,它不仅可以快速上手,而且还可以与你一起成长。虽然在我们的规模中,很少有事情是简单的,但我们仍然设法将复杂性保持在最低水平。
Postgres在Sentry中的使用
首先介绍一下背景,这对了解我们如何使用Postgres是很重要的。我们有两个集群(唯一的数据库),一个存储事件元数据,另一个存储Sentry平台的其余数据。这些集群在运行时都有冷备用的副本,只在灾难或维护的情况下使用。
当数据进入Sentry时,它主要分为三类:
- 事件blob,它是不可改变的,并被写入Riak集群中
- 代表标签的键/值对(如设备名称或操作系统)
- 各种属性,如
last time seen
的聚合
大多数数据都是用SQL存储的,但大型事件Blobs除外。我们依靠Riak的可操作性,但只以最原始的方式利用它——作为键值存储。在SQL中,我们维护对Riak键的引用,将其作为我们的真实来源。
变异
由于本质上的错误,我们经常看到大量重复的、高频率的事件。在内部,我们采取了一个错误,并将其去重到一个集合。之后出现了两种情况:
- 很多相同的错误出现在了同一个集合issue中
- 许多独特的错误正在创建新的聚合(或更新现有的聚合)
对于SQL内的数据,有两类属性会出现严重的变异:
- Cardinality计数器——事件的频率或一个标签的值的数量
Latest Value
属性——比如一个事件最后一次在聚合中出现的时间
计数器
对于第一种情况,我们想增加一个基本的计数器。这就相当于下面的SQL语句:
UPDATE table SET counter = counter + 1 WHERE key = %s;
变正规化的属性
我们的第二种情况是对与事件一起存储的数据进行去规范化。在集合中我们存储了最新事件的一些属性,包括时间戳和标题。
在Sentry中,我们只关心最后的写入,所以我们简单地用最近观察到的值来覆盖现有的值:
UPDATE table SET last_seen = %s WHERE key = %s;
锁
虽然Postgres做锁的方式对这篇文章来说太复杂了,但它是我们所做的一些决定中的一个重要因素。每当一个实体需要写入Postgres时,它就会为该行取出一个共享锁。在Sentry中,这可能是一个严重的问题,因为很多时候一个错误会聚集到同一个实体中,然后引发对同一行的许多更新。当这些更新都试图击中同一个实体(行)时,当你等待获得写锁时,吞吐量就会停滞不前。
在这种情况下,有不同的方法来提高写性能。例如,当处理计数器数据时,数据可以被分割到多行。也就是说,我们可以用(key, counter, partition)来代替单一的(key, counter)行,并将写操作分割开来:
UPDATE table SET counter = counter + 1 WHERE key = %s AND partition = ABS(RANDOM() * 100);
这意味着我们可以为每个实体创建多条行——在上面的例子中,我们有100个分区。当它需要增量时,我们随机挑选一条行来改变,这意味着锁定可以分散到数据库中更多的独特行。这将允许我们有更高的写入吞吐量,但我们需要在以后的某个时间点汇总计数器。虽然这种方法可能会缓解计数器的情况,但它不会解决我们需要存储的其他类型的属性。
我们在这里的核心问题是选择使用Postgres来存储这种类型的数据。Postgres必须提供强大的一致性,因此在锁上花费了大量时间。我们不需要这种级别的一致性,我们也绝对不需要这些锁的成本。通过选择牺牲一致性,我们能够大大增加我们的吞吐量。我们通过缓冲写入来做到这一点。
缓冲
为了解决我们的锁定问题,我们采取了缓冲写入的方法。这意味着我们在一段时间内聚集写,并在一段时间后刷新它们。我们的约束主要围绕一个问题:我们愿意损失多少数据?这个问题控制着缓冲区的刷新间隔。在我们的例子中,它是10秒。
在Sentry中,这意味着当计数器处理时,我们立即写入事件数据——到Riak,因为它是唯一的和不可改变的——我们不会应用计数器、搜索索引或其他许多东西,最长10秒。这就产生了两种我们需要注意的情况:
- UI不会原子式地更新,一些项目会比其他项目更及时地更新。
- 如果我们因为持续的网络故障或其他一些不太常见的情况而失去了缓冲区,就有可能造成数据丢失。
Redis的妙用
Sentry中很多地方用到了Redis,包括从简单的数据缓存一直到时间序列数据的持久性存储等系统。我们对缓冲区的解决方案也不例外。
缓冲区的模式是相当简单的。我们对每个实体都有一组属性,以及它们的当前值(或delta),然后是一组列出这些实体的键。在Redis中,我们将这些存储在两个结构中:
- 每个实体存为一个
hash
- 一个需要刷新的
hash
的set
由于模式的简单性,这意味着操作也更容易推理。每当有一个待处理的写
进来时,我们都要经过以下步骤:
- 将变化写入对应的哈希中。
- 对于计数器我们使用
HINCRYBY
。 - 对于其他值——例如
last seen timestamp
——我们使用HSET
。
- 对于计数器我们使用
- 用
ZADD
把键使用当前时间戳添加到等待set
中。
现在,每隔一段时间——在Sentry的例子中,这是10秒——我们就会刷新待写的内容。这通过类似于cron
的东西完成。
- 使用
ZRANGE
获取所有的键。 - 向我们的队列发送一个作业,其中包括每个待处理的哈希键。
ZREM
给定的键。
当一个worker
收到一个作业时,它会做一些工作,然后应用写入。
- 在一个流水线中。
ZREM
来自pending
的键——如果在这之前有多个作业被排队执行,这可以确保多余的作业可以被noop’d。- 从实体的
hash
键中HGETALL
值。 REM
实体的hash
键。
- 将待定值转换为SQL更新,就像我们没有缓冲时一样。
- 计数器做一个delta更新——
SET counter = counter + %d
。 - 所有其他的值都设置为新的值——
SET value = %s
。
- 计数器做一个delta更新——
关于这个过程的一些说明:
- 我们使用
sorted set
来处理我们只想弹出一个设定的数量的情况(例如,我们想处理100个最老的)。 - 该系统通过在每个节点上放置一个
pending
键,随着Redis节点的增加而线性地扩展。
有了这个模型,我们基本上可以保证一次只更新SQL中的一条记录,这就减轻了大部分锁的争夺。
优化
正如每个系统一样,事情会发生变化,或者出现更好的想法。这对我们的缓冲区来说也是如此。有一些改进和问题我们已经意识到了,但还没有解决:
后进先出 vs 先进先出
目前的实现是一个后进先出的结构。只要我们能够跟上待处理队列的速度,这就可以了。然而,如果我们不这样做,这就意味着最频繁的事件将有最高的优先级。这似乎是个好主意,但往往你想优先处理那些发生得少的事情。
我们可以通过使用ZADD
的NX
参数来解决这个问题。这将确保时间戳不会被更新,如果它已经存在于集合中。不幸的是,这需要一个较新的Redis版本,而由于Sentry被运送到各种环境中,我们觉得不值得为之头痛。
崩溃
在待写作业没有及时处理的情况下,队列很容易被积压。这将导致大量的任务被创建。这些任务在执行时一般都是NOOP,但它们仍然远非免费。
解决这个问题的一个方法是改为pull
模式,而不是push
模式。我们可以有一个worker
,负责不断地弹出待定的更新,这意味着我们永远不会执行重复的工作。
这增加了复杂性,因为我们需要增加一套新的后台服务,并在现有的队列工作者之外扩展这些服务。到目前为止,我们已经避免了这一点,原因与我们不使用较新的Redis功能相同。
数据丢失
worker
有可能失败,但Redis中待向数据库持久化的数据已被清除,这样数据就不会被写入。例如,如果一个进程被OOM杀手杀死,它就已经从Redis中删除了数据,但还没有向Postgres提交更改。
这可以通过增加一个二级in progress
集和复制哈希值来改善。鉴于这种失败的几率很低,我们觉得不值得在这里付出额外的代价,因为这是我们应用中一个非常热的路径。
未来
缓冲区的实现在Sentry中已经存在四年了,而且一路走来只做了一些小的改动。只有时间能告诉我们还要多久才能面临新的约束,但我们相信,在早期选择Redis是正确的选择。