用MongoDB构建大规模社交网络关系链

如今许多App都涉及社交网络,如 Twitter、WhatsApp 和 Facebook。这些平台必须扩展以处理数十亿用户(图节点),这并非易事。构建和维护一个可扩展的社交网络基础设施需要仔细的规划和战略性的数据建模。实际上,像Facebook这样专业的社交网络应用有专门的团队来做这块内容,对其性能进行极致的优化。但对于许多希望加入社交网络功能的小型App,如一个创业公司项目,建立一个团队来做这样的架构显然是不现实也没有必要的。

那么,利用合适的数据建模和存储能否构建一个高性能易扩展的社交网络?答案是肯定的。早期的Facebook使用mysql作为底层存储来构建社交网络,但今天我们可以有更好更高效的存储选择:MongoDB。

问题描述与挑战

首先,我们来描述和简化一下问题。我们希望构建一个社交网络,其中任意用户都可以关注其他用户,每个用户可以显示关注了哪些人,被关注了哪些人,以及关注和被关注数。例如,Twitter上Elon Musk的页面显示他关注了800人,同时有202.8M的粉丝。

点开他的粉丝页面,可以看到每个粉丝具体的信息,如Dr Jordan B Peterson等。

将问题简化后,我们需要支持如下基本功能:关注和取关,查看关注和粉丝数,查看关注的人或粉丝。注意,由于许多名人拥有大量的粉丝,所以Twitter仅支持查看有限个数的粉丝信息。

这些功能虽然看起来很简单,但将这些功能扩展至数十亿用户规模之后就出现了挑战,读取和更新用户之间的社交关系是个非常频繁的操作,数据建模与底层存储是否可以支撑如此之大的数据规模和高并发场景的QPS?

数据建模与可扩展性

为了完成上述功能,数据建模是问题的关键。需要如下6个API:

api/Follow
api/Unfollow
api/GetFollowers
api/GetFollowings
api/GetFollowerCount
api/GetFollowingCount

假设每个用户用一个Uuid进行表示,显然,一种直接的数据建模方式为存储两条记录,每条记录代表一个关系,如:

SrcUuid DstUuid Status 备注
u1 u2 Follow u1关注了u2
u2 u1 Follow u2关注了u1
u3 u4 Follow u3关注了u4

由于u1关注了u2,同时u2也关注了u1,于是u1和u2是互相关注的关系;而u3关注了u4,仅有一条记录,因此只是单向关注关系。

对于这样的数据建模,关注和取关操作的实现也较为容易,插入或删除一条记录即可。获取关注者可以由DstUuid == x查询实现,获取关注者数可由Count(DstUuid == x)查询实现,被关注者亦然。

因此,可以选择使用一张表来存储用户之间的关系数据,数据定义如下:

public class FollowEntry
{
    public string SrcUuid { get; set; }
    public string DstUuid { get; set; }
    public FollowStatus Status { get; set; }
    public DateTime CreateTime { get; set; }
    public DateTime UpdateTime { get; set; }
}

public enum FollowStatus
{
    None = 0,
    Follow = 1
}

这样的数据建模如何能扩展到海量用户?答案是利用分片和增加副本集节点进行扩展,分片可以增加写入能力,而增加副本集节点可以增加读取能力。这样的扩展对上层应用是透明的,不必像mysql的分库分表操作,对上层应用查询会有一定的限制。这也是MongoDB的优势之一。

系统实现

这里具体实现各个API,并通过实际的读取方式来确定所需要的索引和片键。

Follow API

Follow API的实现很简单,Upsert一条关注记录即可,不论之前该记录是否存在,或状态如何:

public async Task FollowAsync(string srcUuid, string dstUuid)
{
    using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
    {
        var update = Builders<FollowEntry>.Update
                    .Set(x => x.Status, FollowStatus.Follow)
                    .Set(x => x.UpdateTime, DateTime.UtcNow)
                    .SetOnInsert(x => x.SrcUuid, srcUuid)
                    .SetOnInsert(x => x.DstUuid, dstUuid)
                    .SetOnInsert(x => x.CreateTime, DateTime.UtcNow);
        var option = new UpdateOptions { IsUpsert = true };

        await _followCollection.UpdateOneAsync(x => x.SrcUuid == srcUuid && x.DstUuid == dstUuid, update, option, cts.Token);
    }
}

Unfollow API的实现类似,仅需要将FollowStatus置为None即可。

这两个API需要索引:(SrcUuid, DstUuid)。

GetFollowers API

GetFollowers API的实现需要考虑分页和最大返回条数限制,这里采用一种较为简捷的实现,返回创建时间最早的前n个Follower:

public async Task<List<FollowEntry>> GetFollowersAsync(string uuid, int n = 10)
{
    using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
    {
        var result = await _followCollection.Find(x => x.DstUuid == uuid && x.Status == FollowStatus.Follow).SortBy(x => x.CreateTime).Limit(n).ToListAsync(cts.Token);
        return result;
    }
}

GetFollowings API的实现类似,仅需要将DstUuid 替换为SrcUuid即可。

这两个API需要索引:(DstUuid, FlowStatus, CreateTime) 和 (SrcUuid, FlowStatus, CreateTime)。将low cardinality field FollowStatus加入索引的原因在于随着时间推移,可能有许多取关的条目存在,加入之后读取效率能稍微高一些,可以根据应用场景进行选择。

GetFollowerCount API

GetFollowerCount API的实现也很直观:

public async Task<long> GetFollowerCountAsync(string uuid)
{
    using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
    {
        var result = await _followCollection.CountDocumentsAsync(x => x.DstUuid == uuid
                                                    && x.Status == FollowStatus.Follow, cancellationToken: cts.Token);
        return result;
    }
}

GetFollowingCount API的实现类似,仅需要将DstUuid 替换为SrcUuid即可。

这两个API可以复用GetFollowers/GetFollowings的索引。

索引与片键

根据上述API的实现,考虑到索引的前缀复用属性,共需要三个索引:

  • (SrcUuid, DstUuid)
  • (DstUuid, FlowStatus, CreateTime)
  • (SrcUuid, FlowStatus, CreateTime)

同时,这些索引也可以满足如"GetTwoUserRelationship()"这样的API,不再赘述。

考虑到用户Id使用Uuid,已经是均匀分布,故不必采用hashed sharding的方式,仅需考虑应用场景中GetFollowers与GetFollowings哪个更常用即可,假设GetFollowings更常用,则可以选择SrcUuid作为分片的片键,这样可以保证一个用户关注的所有人都存在相同的分片中,读取效率更高。

分片可在mongoshell中执行:sh.shardCollection("Test.Test", { "SrcUuid": 1})

分片集群可以极大地扩展系统的上限,并且不需要修改代码,可以在线完成系统容量的扩展。

测试代码

随机生成1000个用户和2000个关注关系,并打印出粉丝大于5的一个用户粉丝信息:

public async Task RunAsync()
{
    var rand = new Random(0);
    var userList = new List<string>();
    // Generate 1000 users
    for (int i = 0; i < 1000; ++i)
    {
        userList.Add(Guid.NewGuid().ToString("N").ToUpper());
    }

    // Generate 2000 relationships
    for (int i = 0; i < 2000; ++i)
    {
        var src = userList[rand.Next(userList.Count)];
        var dst = userList[rand.Next(userList.Count)];
        if (src == dst) continue;
        await FollowAsync(src, dst);
    }

    foreach (var user in userList)
    {
        var followerCount = await GetFollowerCountAsync(user);
        if (followerCount > 5)
        {
            var followers = await GetFollowersAsync(user, 5);
            foreach (var entry in followers)
            {
                Console.WriteLine($"{entry.SrcUuid} follows {user} at {entry.CreateTime}");
            }

            Console.WriteLine($"{user} followers: {followerCount}");
            break;
        }
    }
}

运行结果如下:

7289820430DA4BCD930CFE1067A3DC89 follows 5798C300560A4C09B35F7B48AF57ACF5 at 11/4/2024 1:41:57 PM F2E95841840C45B496C6629181DD2C77 follows 5798C300560A4C09B35F7B48AF57ACF5 at 11/4/2024 1:42:05 PM 7FFB5DE3E3A94AFF8AB487EA82DA2A68 follows 5798C300560A4C09B35F7B48AF57ACF5 at 11/4/2024 1:42:07 PM AE9D38F638C145848997F2E0E8BF62DF follows 5798C300560A4C09B35F7B48AF57ACF5 at 11/4/2024 1:42:09 PM 4E15001E616E441395FBC04CA0F3F36E follows 5798C300560A4C09B35F7B48AF57ACF5 at 11/4/2024 1:42:10 PM 5798C300560A4C09B35F7B48AF57ACF5 followers: 8 Done

使用MongoDB Compass连接部署在MongoDB Atlas上的DB:

讨论与优化

本文所介绍的社交关系链数据建模仅是一个简化版本的设计,旨在演示如何利用MongoDB的分片集群实现大规模社交网络图的构建。如果涉及实际生产环境,还有许多问题需要考虑和优化:

  • Write-heavy or Read-heavy:这里采用userid代表用户,隐去了用户信息,实际实现需要批量读取用户信息的API。如果在FollowEntry中加入了用户信息,虽然提高了读取效率,但更新效率非常低(Write-heavy)。此处的设计是将write-heavy模式转换成了read-heavy模式,实时拉取用户的最新信息。
  • 缓存:虽然MongoDB的性能极佳,但前置缓存(如Redis)依然必要,可以大幅提升系统吞吐率。尤其是对于GetFollowersCount这样的API,缓存展示一个约数对于效率提升至关重要。
  • 复杂业务支持:Twitter的场景比这里复杂很多,不仅要考虑社交关系,更要考虑每个推文的读写与推荐系统的结合,限于篇幅,此处不做深入讨论。

压测证实优化后的社交关系系统可以支撑数十万甚至更高的QPS,性能和吞吐率极佳,非常适合初创公司进行产品快速开发迭代。

Demo代码

填充ConnectionString即可:

using MongoDB.Bson.Serialization.Conventions;
using MongoDB.Driver;

namespace MongoTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var model = new RelModel();

            model.RunAsync().Wait();

            Console.WriteLine("Done");
        }
    }

    public class FollowEntry
    {
        public string SrcUuid { get; set; }
        public string DstUuid { get; set; }
        public FollowStatus Status { get; set; }
        public DateTime CreateTime { get; set; }
        public DateTime UpdateTime { get; set; }
    }

    public enum FollowStatus
    {
        None = 0,
        Follow = 1,
        Deleted = 2
    }

    public class RelModel
    {
        private readonly IMongoClient _mongoClient;
        private readonly IMongoCollection<FollowEntry> _followCollection;

        private const string DatabaseName = "Test";
        private const string CollectionName = "Test";
        public const string ConnectionString = "";

        public RelModel()
        {
            _mongoClient = new MongoClient(ConnectionString);
            var database = _mongoClient.GetDatabase(DatabaseName);

            var ignoreExtraElementsConvention = new ConventionPack { new IgnoreExtraElementsConvention(true) };
            ConventionRegistry.Register("IgnoreExtraElements", ignoreExtraElementsConvention, type => true);
            _followCollection = database.GetCollection<FollowEntry>(CollectionName);
        }

        public async Task RunAsync()
        {
            var rand = new Random(0);
            var userList = new List<string>();
            // Generate 1000 users
            for (int i = 0; i < 1000; ++i)
            {
                userList.Add(Guid.NewGuid().ToString("N").ToUpper());
            }

            // Generate 2000 relationships
            for (int i = 0; i < 2000; ++i)
            {
                var src = userList[rand.Next(userList.Count)];
                var dst = userList[rand.Next(userList.Count)];
                if (src == dst) continue;
                await FollowAsync(src, dst);
            }

            foreach (var user in userList)
            {
                var followerCount = await GetFollowerCountAsync(user);
                if (followerCount > 5)
                {
                    var followers = await GetFollowersAsync(user, 5);
                    foreach (var entry in followers)
                    {
                        Console.WriteLine($"{entry.SrcUuid} follows {user} at {entry.CreateTime}");
                    }

                    Console.WriteLine($"{user} followers: {followerCount}");
                    break;
                }
            }
        }

        public async Task FollowAsync(string srcUuid, string dstUuid)
        {
            using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
            {
                var update = Builders<FollowEntry>.Update
                            .Set(x => x.Status, FollowStatus.Follow)
                            .Set(x => x.UpdateTime, DateTime.UtcNow)
                            .SetOnInsert(x => x.SrcUuid, srcUuid)
                            .SetOnInsert(x => x.DstUuid, dstUuid)
                            .SetOnInsert(x => x.CreateTime, DateTime.UtcNow);
                var option = new UpdateOptions { IsUpsert = true };

                await _followCollection.UpdateOneAsync(x => x.SrcUuid == srcUuid && x.DstUuid == dstUuid, update, option, cts.Token);
            }
        }

        public async Task<List<FollowEntry>> GetFollowersAsync(string uuid, int n = 10)
        {
            using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
            {
                var result = await _followCollection.Find(x => x.DstUuid == uuid && x.Status == FollowStatus.Follow).SortBy(x => x.CreateTime).Limit(n).ToListAsync(cts.Token);
                return result;
            }
        }

        public async Task<long> GetFollowerCountAsync(string uuid)
        {
            using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
            {
                var result = await _followCollection.CountDocumentsAsync(x => x.DstUuid == uuid
                                                            && x.Status == FollowStatus.Follow, cancellationToken: cts.Token);
                return result;
            }
        }
    }
}