用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命令。
- 如果key已经存在,说明聊天室已经创建,调用unwatch命令结束。
- 如果key不存在,则开启事务进行插入。
首先,调用multi命令开启事务,然后,调用rpush命令插入key,值是一个列表,第一个值为空字符串(rpush key “"),然后调用expire命令设置过期时间实现自动删除,例如要两小时自动删除,则实际调用(expire key 7200),接着调用exec执行事务,不考虑网络异常等,如果事务执行成功,聊天室创建成功,否则说明聊天室被其他用户创建。
如何进入聊天室
利用redis在非集群模式可以有多数据库,实现可以是历史消息在db0,用户id在db1。 使用hash可以把一个聊天室的所有id放到一起,并对id分别设置不同的过期时间,所以采用这个数据结构。
具体流程:
- 随机生成一个id。
- 使用hsetnx命令尝试插入用户id,命令的参数key是聊天室名,field是哈希的key是id,value是空字符串(hsetnx key id “")。
- 如失败,说明id已经被使用,回到1重来。
- 如成功,使用hexpireat命令设置id的过期时间,注意该命令在redis7.4.0开始才有,例如假设现在Uinx时间戳是100,过期时间是20秒,则实际调用(hexpireat key 120 FIELDS id)。
因为只有2成功才能执行4,且2同时多个用户执行只有一个成功,所以这里不需要使用事务。
注意这里不需要更新聊天室的过期时间,因为有人但无人发消息的聊天室是不活跃的,和本来就没人的聊天室一样可以定时删除。
如何发送文字消息到聊天室
把文字消息发送到聊天室,只需要把文字消息插入到列表尾部,并且更新id的过期时间。
rpush命令虽然可以做到这一点,但在聊天室不存在时也不会失败,而是产生第一个字符串非空的列表,破坏创建聊天室时产生的不变量,会造成后续获取历史消息时不显示第一条消息。 虽然通过事务和exists命令配合rpush命令可以解决上面的问题,但性能更差。
rpushx命令在key不存在时不插入,解决了上面的问题,所以采用。
具体流程: 在db0
- 调用rpushx key 文字消息。
- 调用expire更新聊天室过期时间。
这里不需要事务,因为每条命令是原子操作就够了,即使前后调用多次expire,旧的调用后执行,最后结果也是,过期时间更新到当前时间+指定时长。
在db1调用hexpireat更新id过期时间。
如何获取历史消息等信息
获取列表的所有元素即可获取历史消息,同时可以获取聊天室还有多久自动删除,以及在线id数。因为实践中,这样可以重复利用一个GET请求响应多个有关的数据,提升性能。
具体流程: 在db0
- 调用lrange key 1 -1,获取列表存的所有历史文字消息。
- 调用ttl key获取聊天室还有多久自动删除。
在db1
- 调用hexpireat更新id过期时间。
- 调用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编码后的图片数据,以此类推视频等。
这样做虽然可以支持发送图片、语音之类的,但有问题。
- 因为redis是串行指行每条命令,所以如果一条命令执行太长时间,其他命令都会等待,造成后续所有操作响应时间增加,而文字消息通常不会很大,不用担心执行用时太长,但图片可能几MB,视频更大。
- 这样无法利用cdn响应图片等数据,限制了优化减少源服务器流量使用。
可以把json字符串改为当type是image时,把data视为图片的url,以此类推视频等。这样可以解决上面的问题,但要注意访问权限,避免发送到聊天室的内容有url就能访问(即检查用户id是否能在db1通过这个聊天室的key查到)。
url可以按这个模板生成:https://hostname/{{聊天室名}}/{{资源名}}
支持限制进入
通过将聊天室名设置为一个复杂的字符串,例如bg3uReMsGpGTUY@W19,因为极不可能被猜到,已经有很好的限制聊天室进入的效果,但不够好,因为
- 使聊天室名无意义了。
- 无法限制进入的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集群只有一个数据库,之前的方案用了两个数据库。
有两种改造方案:
- 准备两个redis集群,之前db0的数据放到一个集群,db1的数据放到另一个集群。缺点是成本更高。
- db0和db1之前直接拿聊天室名作key,现在db0改0+聊天室名作key,db1改1+聊天室名作key。
部分实现的源代码链接:https://github.com/qiulaidongfeng/chatroom