11 | 期中实战:动手写一个简易版的IM系统

你好,我是袁武林。

到上一讲为止,IM的相关课程已经进行过半,在前面的课程中,我们讨论的大部分内容都比较偏理论,你理解起来可能会比较抽象。为了让你对前面讲到的知识有更深入的理解,今天我们就来回顾、梳理近期学习的内容,一起尝试搭建一个简单的IM聊天系统。

在开始之前呢,我先来说明一下IM课程的期中、期末实战的课程计划和设计思路。

期中和期末实战是希望你以自己动手实现为主,提供的Demo主要作为参考,在设计层面上,并不能让你直接用于线上使用。

本次期中实战Demo的主要关注点是:消息的存储、未读数的设计,并以“短轮询”的方式来实现消息的实时触达。希望你能从用户的使用场景出发,来理解消息存储设计的思路,以及未读数独立两套存储的必要性。

另外,在期末实战中,我会从“短轮询”方式调整为WebSocket长连接的方式,并且加上ACK机制、应用层心跳等特性。希望你能在两次实战中,通过对比,真正理解长连接方式相比“短轮询”方式的优势,并且通过ACK机制和应用层心跳,真正理解为什么它们能够解决“数据丢失”和“连接可靠性”的问题。

OK,下面我们说回本次实战。

这个聊天系统要求并不复杂,只需要构建简单的Web界面(没有界面通过命令行能实现也行)。我在这里写了一个简易版的Demo,供你参考。

示例代码你可以在GitHub上下载。

需求梳理

这个简易聊天系统的大概要求有以下几点:

  • 支持用户登录;
  • 双方支持简单的文本聊天;
  • 支持消息未读数(包括总未读和会话未读);
  • 支持联系人页和未读数有新消息的自动更新;
  • 支持聊天页有新消息时自动更新。

需求分析

我们先来分析一下整体需求。

首先,要支持用户登录,先要有“用户”。对应的数据底层需要有一个用户表,用户表的设计可以比较简单,能够支持唯一的UID和密码用于登录即可。当然,如果有用户头像信息,聊天时的体验会更好,所以这里我们也加一下。简单的库表设计可以是这样的:

CREATE TABLE IM_USER (
uid INT PRIMARY KEY,
username VARCHAR(500) NOT NULL,
password VARCHAR(500) NOT NULL,
email VARCHAR(250) DEFAULT NULL,
avatar VARCHAR(500) NOT NULL
);

对应的实体类User字段和库表一致,这里就不罗列了,我们需要设计用户通过邮箱和密码来登录。因为课程主要是涉及IM相关的知识,所以这里对用户信息的维护可以不做要求,启动时内置几个默认用户即可。

有了用户后,接下来就是互动了,这一期我们只需要关注简单的文本聊天即可。在设计中,我们需要对具体的聊天消息进行存储,便于在Web端使用,因此可以简单地按照“02 | 消息收发架构:为你的App,加上实时通信功能”中讲到的消息存储来实现此项功能。

消息的存储大概分为消息内容表、消息索引表、联系人列表,这里我用最基础的字段来给你演示一下。单库单表的设计如下:

消息内容表:

CREATE TABLE IM_MSG_CONTENT (
mid INT AUTO_INCREMENT  PRIMARY KEY,
content VARCHAR(1000) NOT NULL,
sender_id INT NOT NULL,
recipient_id INT NOT NULL,
msg_type INT NOT NULL,
create_time TIMESTAMP NOT NUll
);
消息索引表:

CREATE TABLE IM_MSG_RELATION (
owner_uid INT NOT NULL,
other_uid INT NOT NULL,
mid INT NOT NULL,
type INT NOT NULL,
create_time TIMESTAMP NOT NULL,
PRIMARY KEY (`owner_uid`,`mid`)
);
CREATE INDEX `idx_owneruid_otheruid_msgid` ON  IM_MSG_RELATION(`owner_uid`,`other_uid`,`mid`);
联系人列表:

CREATE TABLE IM_MSG_CONTACT (
owner_uid INT NOT NULL,
other_uid INT NOT NULL,
mid INT NOT NULL,
type INT NOT NULL,
create_time TIMESTAMP NOT NULL,
PRIMARY KEY (`owner_uid`,`other_uid`)
);
  • 消息内容表:由于只是单库单表,消息ID采用自增主键,主要包括消息ID和消息内容。
  • 消息索引表:使用了索引“归属人”和消息ID作为联合主键,可以避免重复写入,并增加了“归属人”和“关联人”及消息ID的索引,用于查询加速。
  • 联系人列表:字段和索引表一致,不同点在于采用“归属人”和“关联人”作为主键,可以避免同一个会话有超过一条的联系人记录。

消息相关实体层类的数据结构和库表的字段基本一致,这里不再列出,需要注意的是:为了演示的简单方便,这里并没有采用分库分表的设计,所以分库的Sharding规则你需要结合用户消息收发和查看的场景,多加考虑一下库表的设计。

OK,有了用户和消息存储功能,现在来看如何支持消息未读数。

在“07 | 分布式锁和原子性:你看到的未读消息提醒是真的吗?”一讲中,我讲到了消息未读数在聊天场景中的重要性,这里我们也把未读数相关的功能加上来。

未读数分为总未读和会话未读,总未读虽然是会话未读之和,但由于使用频率很高,会话很多时候聚合起来性能比较差,所以冗余了总未读来单独存储。比如,你可以采用Redis来进行存储,总未读可以使用简单的K-V(Key-Value)结构存储,会话未读使用Hash结构存储。大概的存储格式如下:

总未读:
owneruid_T, 2

会话未读:
owneruid_C, otheruid1, 1
owneruid_C, otheruid2, 1

最后,我们一起来看看如何支持消息和未读自动更新。

在“03 | 轮询与长连接:如何解决消息的实时到达问题?”一讲中,我讲到了保证消息实时性的三种常见方式:短轮询、长轮询、长连接。对于消息和未读的自动更新的设计,你可以采用其中任意一种,我实现的简版代码里就是采用的“短轮询”来在联系人页面和聊天页面轮询未读和新消息的。实现上比较简单,Web端核心代码和服务端核心代码如下。

Web端核心代码:

newMsgLoop = setInterval(queryNewcomingMsg, 3000);
$.get(
    '/queryMsgSinceMid',
    {
        ownerUid: ownerUid,
        otherUid: otherUid,
        lastMid: lastMid
    },
    function (msgsJson) {
        var jsonarray = $.parseJSON(msgsJson);
        var ul_pane = $('.chat-thread');
        var owner_uid_avatar, other_uid_avatar;
        $.each(jsonarray, function (i, msg) {
            var relation_type = msg.type;
            owner_uid_avatar = msg.ownerUidAvatar;
            other_uid_avatar = msg.otherUidAvatar;


            var ul_pane = $('.chat-thread');
            var li_current = $('<li></li>');//创建一个li
            li_current.text(msg.content);
            ul_pane.append(li_current);
        });
    });
);
服务端核心代码:

List<MessageVO> msgList = Lists.newArrayList();
List<MessageRelation> relationList =    relationRepository.findAllByOwnerUidAndOtherUidAndMidIsGreaterThanOrderByMidAsc(ownerUid, otherUid, fromMid);

/** 先拼接消息索引和内容 */
User self = userRepository.findOne(ownerUid);
User other = userRepository.findOne(otherUid);
relationList.stream().forEach(relation -> {
    Long mid = relation.getMid();
    MessageContent contentVO = contentRepository.findOne(mid);
    if (null != contentVO) {
        String content = contentVO.getContent();
        MessageVO messageVO = new MessageVO(mid, content,   relation.getOwnerUid(), relation.getType(), relation.getOtherUid(), relation.getCreateTime(), self.getAvatar(), other.getAvatar());
    msgList.add(messageVO);
    }
});

/** 再变更未读 */
Object convUnreadObj = redisTemplate.opsForHash().get(ownerUid + Constants.CONVERSION_UNREAD_SUFFIX, otherUid);
if (null != convUnreadObj) {
    long convUnread = Long.parseLong((String) convUnreadObj);
    redisTemplate.opsForHash().delete(ownerUid +    Constants.CONVERSION_UNREAD_SUFFIX, otherUid);
    long afterCleanUnread =     redisTemplate.opsForValue().increment(ownerUid +    Constants.TOTAL_UNREAD_SUFFIX, -convUnread);

/** 修正总未读 */
if (afterCleanUnread <= 0) {
    redisTemplate.delete(ownerUid + Constants.TOTAL_UNREAD_SUFFIX);
}
return msgList;

这里需要注意两个地方:

  • 其一,由于业务上使用的“消息对象”和存储层并不是一一对应关系,所以一般遇到这种情况,你可以为展现层在实体层基础上创建一些VO对象来承接展示需要的数据。比如,我在这里创建了一个用于消息展现的VO对象MessageVO。
  • 其二,在未读数的实现上,由于只是演示,这里并没有做到两个未读的原子变更,所以可能存在两个未读不一致的情况,因此上方代码最后部分我简单对总未读做了一个为负后的修正。

其他实现上的注意点

前面我们从需求梳理出发,把这个简易聊天系统核心部分的设计思路和核心代码讲了一下,也强调了一下这套代码实现中容易出现问题的地方。除此之外,我希望在代码实现中,你还能考虑以下这些问题,虽然这些不是IM系统实现中的核心问题,但对于代码的整洁和设计的习惯培养也是作为一个程序员非常重要的要求。

代码分层

首先是代码分层方面,我们在代码实现上,应该尽量让表现层、业务层和持久化层能分离清楚,做到每一层的职责清晰明确,也隔离了每一层的实现细节,便于多人协作开发。

表现层控制数据输入和输出的校验和格式,不涉及业务处理;业务层不涉及展现相关的代码,只负责业务逻辑的组合;持久化层负责和底层资源的交互,只负责数据的写入、变更和获取,不涉及具体的业务逻辑。

依赖资源

另外,对于代码中使用到的Web服务器、DB资源、Redis资源等,我们尽量通过Embedding的方式来提供,这样便于在IDE中执行和代码的迁移使用。

小结

这一节课主要是安排一次实战测试,让你来实现一个简易的聊天系统,涉及的IM相关知识包括:消息存储的设计、消息未读的设计、消息实时性的具体实现,等等。

在实现过程中,希望你能做到合理的代码分层规划,通过Embedding方式提升代码对IDE的友好性及代码的可迁移性。相信通过这一次理论与实践相结合的动手实战,能够帮助你进一步加深对IM系统核心技术的理解。

千里之行,积于跬步。通过脚踏实地的代码实现,将理论知识变成真正可用的系统,你的成就感也是会倍增的。

期中实战的演示代码并没有完全覆盖之前课程讲到的内容,所以如果你有兴趣,也非常欢迎你在代码实现时加入更多的特性,比如长连接、心跳、ACK机制、未读原子化变更等。如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。感谢你的收听,我们下期再见。

精选留言

  • 王蒙

    2019-10-17 08:27:55

    Embedding方式是什么意思
    作者回复

    就是嵌入到jvm里的一些中间件和db,web容器这些,随着jvm启动也能跟着一起run起来,用起来比较方便。

    2019-10-18 21:01:41

  • Ricky Fung

    2019-10-24 09:38:27

    建议 期中 和 期末考试作业 代码可以分两个分支,这样大家看起来更直观。
  • 程序员半支烟

    2019-09-20 08:37:09

    只是基于redis吗?没用到netty吗?
    作者回复

    期中实战只是个简易版demo哈,期末的实战会基于目前轮询的方式进行长连接改造,再加上ack和心跳等功能,这样也让大家能对“消息推送实时性”的实现方案有一个比对。
    另外,实战的目的是让大家自己尝试结合课程前面的内容来练手加深对知识的理解,所以提供的demo并不具备线上可用性,只是一个简单的参考。

    2019-09-20 12:26:36

  • 林晓威

    2023-01-16 16:07:25

    老师觉得聊天数据库用mongodb会不会更合适?
  • kamida

    2020-03-12 03:59:43

    老师 我想问一下 分库分表的问题 如果MSG_CONTENT的partition是mid 而MSG_RELATION的partition key 是onwer id 那么如果我们要查询两个用户之间所有的msg及其内容的话 我们需要去每一个DB shard去查找msg_content table 这样会不会很慢 或者这个查询需求是不必要的?
    作者回复

    这个查询需求是存在的,但由于一般我们都是分页查询,所以针对某一页的消息内容进行multi get的并发查询实际上性能上也是可以接受的。

    2020-03-13 15:57:37

  • 飞翔

    2019-09-22 21:56:43


    CREATE INDEX `idx_owneruid_otheruid_msgid` ON IM_MSG_RELATION(`owner_uid`,`other_uid`,`mid`);
    老师 消息索引表中 为什么要创作(`owner_uid`,`other_uid`,`mid`); 这三个的联合索引 呀
    作者回复

    分页的时候会用到。比如:
    select msgid from IM_MSG_RELATION where owner_uid=? and other_uid=? and mid <= ? order by mid desc limit ?,?

    2019-09-23 19:15:38

  • 飞翔

    2019-09-22 21:45:47

    消息内容表:

    CREATE TABLE IM_MSG_CONTENT (
    mid INT AUTO_INCREMENT PRIMARY KEY,
    content VARCHAR(1000) NOT NULL,
    sender_id INT NOT NULL,
    recipient_id INT NOT NULL,
    msg_type INT NOT NULL,
    create_time TIMESTAMP NOT NUll
    );


    老师 消息内容表中的sender_id INT NOT NULL,
    recipient_id INT NOT NULL, 这两个字段是不是有些多余?
    作者回复

    嗯,是冗余了一下,主要是方便离线统计和后台使用,业务上可以不需要。

    2019-09-23 19:08:50

  • 山头

    2019-09-21 14:30:13

    消息索引表:

    CREATE TABLE IM_MSG_RELATION (
    owner_uid INT NOT NULL,
    other_uid INT NOT NULL,
    mid INT NOT NULL,
    type INT NOT NULL,
    create_time TIMESTAMP NOT NULL,
    PRIMARY KEY (`owner_uid`,`mid`)
    );
    CREATE INDEX `idx_owneruid_otheruid_msgid` ON IM_MSG_RELATION(`owner_uid`,`other_uid`,`mid`);
    ownerid otherid是什么意思?张三给李四发一条消息,在这个表里存几条数据呢
    作者回复

    会存两条,咱们课程2里面有讲过的哦

    2019-09-23 19:06:35

  • ahack

    2021-10-16 00:24:13

    新手,redis这个表看不懂要怎么建,有没有大佬jies一下呢
  • DeenJun

    2020-11-08 00:05:41

    请教个问题:之前讲的IM服务划分为业务逻辑和连接层两部分,连接层负责维护连接和编解码消息。并且看之前课程讲,一般各厂都设计了一套私有协议而没有用MQTT,但是这部分讲得比较略。我的问题是,长连接的两端发送请求都是oneway的吗?有req-resp模型吗?比如说客户端通过长连接发送了一个请求A,这时候服务端又推送了一条消息,然后再发送请求A对应的response。客户端就必须要能够区分哪个包是对应哪个请求的resp还是仅仅是服务端主动推送的。服务端也同理。一般是怎么做呢?长连接的协议需要设计成 oneway + req-resp都支持这种模式,还是说就仅仅是oneway这种模式,推了就完事儿,需要resp的请求走另外的服务不走长连接?如果在协议中支持,有推荐的开源协议吗?一般长连接的私有协议需要考虑连接的多路复用吗?问题有点多,期待老师的回答…
  • Geek_908e99

    2019-11-14 15:46:45

    老师我看更新未读数的逻辑并没有用到redis事务,我看到的就是下面两行,这个实现不能保证原子性吧:

    /**更未读更新 */
    redisTemplate.opsForValue().increment(recipientUid + "_T", 1); //加总未读
    redisTemplate.opsForHash().increment(recipientUid + "_C", senderUid, 1); //加会话未读
    作者回复

    嗯,这个demo没有用lua来保障原子性,所以是会存在并发更新的一致性问题,有兴趣的话可以尝试来优化改造试试哈,核心lua代码在文章中有。

    2019-11-19 15:42:52

  • 五点半先生

    2019-10-20 07:50:10

    搬运,https://github.com/coldwalker/Sample
  • Geek_defa2f

    2019-10-18 14:12:56

    能不能期中期末实战的代码分开部署在github上,现在才学到,发现期中的代码被期末的代码覆盖了。。。
    作者回复

    期末的代码基本没有覆盖期中的哈,都是独立的功能和实现,页面也都是独立的。

    2019-10-18 21:30:50

  • yic

    2019-10-05 16:33:32

    消息索引表:

    CREATE TABLE IM_MSG_RELATION (
    owner_uid INT NOT NULL,
    other_uid INT NOT NULL,
    mid INT NOT NULL,
    type INT NOT NULL,
    create_time TIMESTAMP NOT NULL,
    PRIMARY KEY (`owner_uid`,`mid`)
    );
    CREATE INDEX `idx_owneruid_otheruid_msgid` ON IM_MSG_RELATION(`owner_uid`,`other_uid`,`mid`);
    老师,消息索引表这么创建,请教一下群发(500人群)消息,是不是要插入500条记录? 如果是插库的话,性能能保证吗?
    作者回复

    群发消息qps很高的话就不要用mysql这种关系型数据库啦,另外也不需要发件人维度的消息存储,可以考虑采用hbase这类nosql数据库来存储索引。

    2019-10-09 20:53:02

  • 王棕生

    2019-09-25 00:09:20

    感谢老师的总结和源码分享!
    作者回复

    😺 谢谢

    2019-09-25 19:42:42

  • YidWang

    2019-09-24 23:57:06

    消息 没有重复 设计
  • Geek_发现

    2019-09-24 14:34:05

    老师你好,我启动项目报错org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'embededRedis': Invocation of init method failed; nested exception is java.lang.RuntimeException: Can't start redis server. Check logs for details.
    我redis是启动了的,端口号也是6379,怎么回事呢?
    作者回复

    还有其他报错吗?比如“This redis server instance is already running...”或者“Failed to start Redis instance”。可以断点跟进到start方法里去看看。

    2019-09-25 19:29:02

  • 给我点阳光就灿烂

    2019-09-23 13:03:45

    即使通讯的消息可不可以不存在数据库中而已消息队列的形式代替呢
    作者回复

    存消息队列的问题在于你需不需要按会话维度啥的来进行查询,分页等等。如果不需要,可以只根据uid维度来暂存消息和信令。

    2019-09-23 19:33:40

  • leslie

    2019-09-23 10:59:06

    期中考试 、、、还是等期末考试的时候一起做吧,看的懂写不来,出去的都是伪代码:忙起来就发现写这个东西自己的Coding太差了,被Coding能力拖后腿了:谁让这是DBA和OPS的通病呢、、、
    作者回复

    建议还是动手写一写,一码胜千言~

    2019-09-23 19:31:51

  • 飞翔

    2019-09-23 03:48:06

    老师 有一个问题
    对于redis 事务
    redisTemplate.multi();
    redisTemplate.opsForValue().increment(recipientUid + "_T", 1); //加总未读
    redisTemplate.opsForHash().increment(recipientUid + "_C", senderUid, 1); //加会话未读
    redisTemplate.exec();

    假设第一个加总未读失败, 事务并不会停止,而是继续进行,第二个加会话未读, 这样不也是数据就不一致了嘛, redis 事务完全和没有一样呀?
    作者回复

    是的,redis的事务只保证这些命令原子执行,执行过程中就算有命令失败,队列中的其他命令也会被执行,所以调用方需要根据返回结果来进行二次处理。

    2019-09-23 19:23:33