秋来冬风的博客

2025-4-21

用redis构建聊天室

此博客记录我用redis构建聊天室的一个方案。

首先,要实现的功能是聊天室可以被多个用户进入,在里面互相发消息聊天,能显示在线id数,聊天室会在一段时间没人发消息后自动删除。

实现

每个聊天室有一个名字,可以使用这个名字作为操作redis时的key。

如何保存历史消息

性能太差不采用的

redis的string可以保存历史消息,通过将所有消息序列化为json之类保存,这样性能最差,因为消息要被重复的序列化和反序列化,所以不采用。

不符合要求不采用的

redis的发布订阅,可以构建非常简单的聊天室,但不能保存历史消息,且无法确保消息一定会被接收,所以不采用。 redis的位图,地理位置索引,HyperLogLog不能实现保存消息,所以不采用。

实现更复杂不采用的

redis的哈希可以通过key是聊天室第n条发出消息的n,value是消息实现聊天室,意味着需要一个不断递增的n,增加了实现的复杂性,所以不采用。 redis的集合和有序集合,可以通过,聊天室第n条发出消息的n+消息的字符串插入到集合实现聊天室,同样因为需要不断递增的n,所以不采用。 redis的流相对其他数据类型更复杂,所以不采用。

最终采用

redis的列表支持保存历史消息,可以被定时删除,而且通过lrange改参数start和end,很方便实现分页显示,不需要不断递增的n,所以采用。

如何创建聊天室

通过在redis插入一个表示聊天室的key,值为列表。

首先,要使用watch命令监视在redis表示聊天室的key,然后调用exists命令。

首先,调用multi命令开启事务,然后,调用rpush命令插入key,值是一个列表,第一个值为空字符串(rpush key “"),然后调用expire命令设置过期时间实现自动删除,例如要两小时自动删除,则实际调用(expire key 7200),接着调用exec执行事务,不考虑网络异常等,如果事务执行成功,聊天室创建成功,否则说明聊天室被其他用户创建。

如何进入聊天室

利用redis在非集群模式可以有多数据库,实现可以是历史消息在db0,用户id在db1。 使用hash可以把一个聊天室的所有id放到一起,并对id分别设置不同的过期时间,所以采用这个数据结构。

具体流程:

  1. 随机生成一个id。
  2. 使用hsetnx命令尝试插入用户id,命令的参数key是聊天室名,field是哈希的key是id,value是空字符串(hsetnx key id “")。
  3. 如失败,说明id已经被使用,回到1重来。
  4. 如成功,使用hexpireat命令设置id的过期时间,注意该命令在redis7.4.0开始才有,例如假设现在Uinx时间戳是100,过期时间是20秒,则实际调用(hexpireat key 120 FIELDS id)。

因为只有2成功才能执行4,且2同时多个用户执行只有一个成功,所以这里不需要使用事务。

注意这里不需要更新聊天室的过期时间,因为有人但无人发消息的聊天室是不活跃的,和本来就没人的聊天室一样可以定时删除。

如何发送文字消息到聊天室

把文字消息发送到聊天室,只需要把文字消息插入到列表尾部,并且更新id的过期时间。

rpush命令虽然可以做到这一点,但在聊天室不存在时也不会失败,而是产生第一个字符串非空的列表,破坏创建聊天室时产生的不变量,会造成后续获取历史消息时不显示第一条消息。 虽然通过事务和exists命令配合rpush命令可以解决上面的问题,但性能更差。

rpushx命令在key不存在时不插入,解决了上面的问题,所以采用。

具体流程: 在db0

  1. 调用rpushx key 文字消息。
  2. 调用expire更新聊天室过期时间。

这里不需要事务,因为每条命令是原子操作就够了,即使前后调用多次expire,旧的调用后执行,最后结果也是,过期时间更新到当前时间+指定时长。

在db1调用hexpireat更新id过期时间。

如何获取历史消息等信息

获取列表的所有元素即可获取历史消息,同时可以获取聊天室还有多久自动删除,以及在线id数。因为实践中,这样可以重复利用一个GET请求响应多个有关的数据,提升性能。

具体流程: 在db0

  1. 调用lrange key 1 -1,获取列表存的所有历史文字消息。
  2. 调用ttl key获取聊天室还有多久自动删除。

在db1

  1. 调用hexpireat更新id过期时间。
  2. 调用hlen命令(hlen key),获取在线id数。

如何退出聊天室

在db1调用hdel key id即可。 可以客户端和服务端定时通信,客户端长时间无响应就自动退出聊天室。

注意不应立刻检查是否无用户在线,因为即使所有用户都不在,可能只是因为网络等原因意外下线,很快就回来,所以即使无人在线也不意味着可以删除聊天室。

这样一个基本的聊天室就实现,可以优化。

优化:只获取新消息

在实践中,用户之间发消息,旧的消息已经在用户那里有了,不需要每次到重新获取,所以可以利用这一点优化。 只需要把上面“如何获取历史消息等信息”的在db0的流程2改为调用调用lrange 用户已获取的消息数 列表的长度即可。

手动删除聊天室

只要在db0和db1执行del key就能实现。

支持发送多媒体消息

在实践中,可能发送的消息不是文字而是图片之类的。 支持这一点的简单办法是之前列表的每个元素是一条文字消息,现在改为json字符串,消息在里面,类似{type:“text”,data:“message”}这样有两个字段的,当type是text,把data的值视为文字消息,当type是image时,把data视为base64编码后的图片数据,以此类推视频等。

这样做虽然可以支持发送图片、语音之类的,但有问题。

  1. 因为redis是串行指行每条命令,所以如果一条命令执行太长时间,其他命令都会等待,造成后续所有操作响应时间增加,而文字消息通常不会很大,不用担心执行用时太长,但图片可能几MB,视频更大。
  2. 这样无法利用cdn响应图片等数据,限制了优化减少源服务器流量使用。

可以把json字符串改为当type是image时,把data视为图片的url,以此类推视频等。这样可以解决上面的问题,但要注意访问权限,避免发送到聊天室的内容有url就能访问(即检查用户id是否能在db1通过这个聊天室的key查到)。

url可以按这个模板生成:https://hostname/{{聊天室名}}/{{资源名}}

支持限制进入

通过将聊天室名设置为一个复杂的字符串,例如bg3uReMsGpGTUY@W19,因为极不可能被猜到,已经有很好的限制聊天室进入的效果,但不够好,因为

  1. 使聊天室名无意义了。
  2. 无法限制进入的id。

利用db0每个列表第一个元素都是可被用作其他通途的字符串,可以把它变成一个配置字符串,例如有一个配置结构体:

type Config struct{
    // Password 设置进入聊天室的验证密码的哈希值。
    Password string
    // MaxIdNum 设置进入聊天室的最大id数。
    // 为0表示不限制。
    MaxIdNum int
}

可以把它序列化为json字符串,保存到列表第一个元素,然后进入聊天室时检查MaxIdNum,每次访问聊天室(发送消息,获取消息等)到要携带上密码的哈希值,服务器在进行操作前先检查密码哈希值是否一致,一致才执行操作,就实现了限制进入并解决上面的问题。

支持显示发送者昵称

可以在配置结构体加一个字段:

    // Name 记录id到用户昵称的定义。
    Name map[string]string

消息json字符串加一个字段send,值为发送者id。 然后进入聊天室时要求用户设置昵称,之后获取消息后通过发送者id,可以利用配置结构体的Name字段查到发送者的昵称。

支持修改自动过期时间

可以在配置结构体加一个字段:

    // TTL 表示多久没新消息删除聊天室。
    TTL time.Duration

然后每次有新消息时使用这个字段确定自动过期时间。

支持禁言

可以在配置结构体加一个字段:

    // Prohibit 记录被禁言的id。
    Prohibit []string

被禁言的id就加入这个字段。 然后发送消息时,先检查发送者id是否在配置结构体的Prohibit字段中,如果在,不准发送消息。 扩展这个字段为类似这样:

    // Prohibit 记录被禁言的id和禁言时间。
    Prohibit []struct{
        // Id 是被禁言的id。
        Id string
        // End 是解除禁言时间。
        // End.IsZero() 返回true表示永久禁言
        End time.Time
    }

还可以实现只禁言一段时间。

这里故意不用redis的集合存被禁言的id,因为这样只要一条lrange命令就能获取所有消息和配置信息,用集合意味着需要执行的命令数翻倍。

支持撤回消息

由于redis原生不支持列表直接删除指定元素,所以需要用事务和一些命令模拟这一操作,可以实现,但性能差,所以不采用。

redis的lset命令可以修改指定index的元素,可以通过(lset key index “")实现撤回,所以采用。 注意“优化:只获取新消息”假设消息发出后不变,撤回消息打破了这一不变量。所以为了正确实现,需要再调用rpushx命令插入一条特殊的json字符串,例如{type:“change”,data:index},用来告知客户端,某条消息撤回了,并且客户端必须检查每一条消息是否是空字符串,非空的才显示。

如果要支持显示谁撤回了消息,可以通过实现撤回的lset命令,参数空字符串改为例如这样{type:“change”,data:“id”},客户端需要相应更新来处理这样的json字符串。

支持redis集群

redis命令是串行执行的,所以它的性能受限于单核是有上限的,尽管是内存数据库性能很高,但最高大约10万+rps(测set命令的数据),开io多线程(用多个线程处理网络io,但命令还是一个线程执行)可以高一些,如果需要支持非常多人在线,需要使用redis集群。 redis集群将数据分布到多个redis实例提供性能,上面的方案需要改造来支持的是,因为redis集群只有一个数据库,之前的方案用了两个数据库。

有两种改造方案:

  1. 准备两个redis集群,之前db0的数据放到一个集群,db1的数据放到另一个集群。缺点是成本更高。
  2. db0和db1之前直接拿聊天室名作key,现在db0改0+聊天室名作key,db1改1+聊天室名作key。

部分实现的源代码链接:https://github.com/qiulaidongfeng/chatroom

Tags: