通知系统的存储设计

街旁通知随着功能的演进和不断增长的数据规模,底层存储经历了从 MongoDB 迁移到 CrabDB 再到 Redis 的过程。通知系统包含以下几个组成部分:数据存储,业务逻辑,交互逻辑,正文拼装,服务接口,任务队列。本文只讨论数据存储部分,着重从数据特点、查询维度、存储结构等方面,介绍当前遇到的挑战和解决方法。

通知是什么

用户接收到的站内消息提醒,通常和某一行为关联在一起。通知可以由用户发给用户,也可以由系统发给用户,是一对一的。

例如,评论了某人的状态,加某人为好友,收藏了某人的攻略,获得了徽章。

数据特点

数据总量大,规模仅次于签到;
单条数据很小;
热点分布不均匀;
查询维度很多;
用户通知有上限;
数据字段可变;
可以滞后更新。

搞清数据的特点是设计存储结构的第一步,可供选择的存储技术有很多,没有绝对的好与不好,只有适合与不适合。架构不能脱离业务而谈,明确数据特点才能为技术取舍找到更好的利弊平衡点。

查询维度

查询用户某些类型的通知列表 to_user, type
查询用户某些类型的未读通知数 to_user, type, unread
删除用户对动态标记赞的通知 to_user, from_user, type, post
把用户动态被回复的通知标记已读 to_user, from_user, type, unread, post
把地点册留言相关的通知标记已读 to_user, type, venuelist_id, unread
……

查询维度表明了我们如何去检索数据,这决定了怎样为数据建立有效的索引。上面几个例子看出,通知数据的查询维度多种多样,很难用两三个索引覆盖查询的全部情况。

这里展开说几句,事实上社交服务的复杂性,主要就体现在数据量大且查询维度多上面,解决问题没有标准做法,就我看到业内主要思路有:
增加缓存,提高命中率;增加冗余,牺牲强一致性;离线计算;专有存储和计算服务;分布式扩展;降维。解决方案通常是上述几个的综合运用。

对象属性

必须字段 required
主键 ID,类型 type,接收者 to_user,时间 create_on,已读 unread

选择字段 optional
发送者 from_user,状态 post_id
发送者 from_user,地点册 venuelist_id
徽章 badge_id
……

存储结构

通知数据 notif:{id} -> Hash

Example
redis> HGETALL notif:117527377
 1) "c"
 2) "1378106545"
 3) "fu"
 4) "836401721"
 5) "tu"
 6) "920809610"
 7) "ur"
 8) "0"
 9) "p"
10) "106903224"
11) "t"
12) "1"

用户通知列表 user:{user.id}:notif -> List

Example
redis> LRANGE user:920809610:notif -10 -1
 1) "117156684"
 2) "117298016"
 3) "117301030"
 4) "117303982"
 5) "117307963"
 6) "117309837"
 7) "117309855"
 8) "117321952"
 9) "117330242"
10) "117335231"

通知 ID 生成 notif:last.id -> String

Example
redis> INCR notif:last.id
(integer) 117527378

查询算法

这次用了一点激进的方案,就是在 Redis 内部嵌入 Lua 脚本做查询过滤。这还是一个比较新的特性,前期也做了不少测试,还为此专门升级了客户端 driver。下面从 Python 客户端 和 Lua 脚本两方面演示代码,为了简化说明,代码相比实际有所改动。

Python Interface

rds_notif.eval(LUA_SCRIPT, 1, key, types, cond, limit, max_id, since_id)

Lua Script

if (max_id <= 0 or notif_id < max_id) and (notif_id > since_id) then
    local notif = bulk_to_table(redis.call('HGETALL', notif_key))
    local type_selected = false
    for j = 1, #types do
        if notif['t'] == tostring(types[j]) then type_selected = true end
    end
    if type_selected or #types == 0 then
        local cond_selected = true
        for k, v in pairs(cond) do
            if notif[k] ~= tostring(v) then
                cond_selected = false
                break
            end
        end
        if cond_selected then
            if offset <= 0 then table.insert(result, notif_ids[index]) end
            offset = offset - 1
        end
    end
    if limit > 0 and #result >= limit then
        return result
    end
end

这个查询接口的特色是支持 offset 和 max_id ,当上层接口需要查看更多时,可以灵活支持翻页方式还是向后加载。

性能对比

取一天中二十四个时段耗时的中位数

使用 Redis 比 使用 CrabDB 耗时时间上升了一些,这是 Lua 与 C 执行效率上的差距。

取一天中二十四个时段耗时最大的10%

使用 Redis 比 CrabDB 耗时有两个数量级的下降,分析这是 CrabDB 在进行磁盘 IO 时带来很大的性能下降。Redis 在内存管理上更合理,减少磁盘读写次数,带来更稳定的性能。

当我完整迁移过数据以后,通知系统的数据规模减少至原来的四分之一,响应速度也提升一个数量级。截止完成这篇文章,线上跑了两个多月没出问题,我想还是成功的。

限于文章篇幅,这里仅介绍了一些思路。实际操作过程中,还有很多可圈点的地方。比如我们对 Redis 集群的管理,比如曾经为什么会选择 MongoDB 又迁移到 CrabDB 等等。技术是以点带面的,但当讲述一件事情,希望尽量只关注在单一问题本身。

这篇文章整理自内部分享,幻灯片 PDF 下载在这里。幻灯片最后有两页关于不同数据库优缺点的比较,有兴趣可以看一下。

-EOF-