Skip to content

面试官:你刚才说的负责过“在线接诊”这个功能模块,那么这个模块具体是干啥的?你能说说你负责了哪些部分吗?

面试者: 没问题。 “在线接诊”简单说就是:用户点一下“找医生”,系统把消息推给医生,医生点“接诊”,然后俩人就能开始聊天了。 听起来简单,但其实背后要处理很多事。 我主要负责了三块:

  1. 用户发起问诊——比如选科室、描述病情、上传图片这些;
  2. 医生端的接诊通知和抢单逻辑——怎么让医生第一时间知道有新问诊,怎么防止多个医生同时接同一个单;
  3. 接诊后的实时聊天功能——用户和医生能发文字、图片,消息要秒到,不能丢。

其中最难、也最核心的,就是消息怎么实时推给医生,还有接诊那一刻不能出错,比如两个医生同时点“接诊”,结果都接到了,那肯定不行。


面试官:嗯,那你说“消息要实时推给医生”,你们是怎么做的?总不能让用户一直刷新页面吧?

面试者: 哈哈,确实不能刷新。 最开始我们想过“轮询”——就是让医生的手机每2秒问一次“有没有新问诊?”,但这样太耗电、耗流量,服务器压力也大,一条消息可能延迟好几秒,用户体验很差。

后来我们就改用了一种叫 WebSocket 的技术,它能建立一个“长连接”。 简单说,就像医生打开App的时候,就跟我们服务器打了个“电话”,这个电话一直挂着,不挂断。

一旦有新用户发起问诊,服务器就通过这个“电话线”,立刻把消息推过去,医生手机“叮”一下就收到了,基本是秒级的。

我们用的是 Netty 这个框架来搭这个通信网关,因为它处理高并发特别强,撑得住大量医生同时在线。


面试官:听起来不错。但你说“电话一直挂着”,那万一线断了呢?比如医生手机切到后台,或者网络不好,消息会不会丢?

面试者问得太准了!这正是我们踩过的大坑。

刚开始上线那会儿,真有医生反馈:“我明明在线,怎么没收到新问诊?” 我们一查日志,发现是连接断了,但系统没及时发现,导致消息发不出去。

后来我们发现,光靠WebSocket不行,还得加“心跳机制”。

啥是心跳? 就是每隔30秒,服务器和手机互相发个“你还活着吗?”的小消息。 如果连续两次没收到回复,我们就认为“这人掉线了”,赶紧把连接关掉,避免资源浪费。

但问题又来了:连接断了,新问诊消息怎么办?

我们想了两个办法:

  1. 消息存数据库:所有问诊请求,不管有没有推成功,都先存到MySQL里,标记状态。
  2. 医生上线后主动查:医生重新打开App时,会主动问服务器:“我有没有漏掉的问诊?”系统就从数据库里把没推送成功的找出来,补发一遍。

这样就做到了“消息不丢,最终能收到”。


面试官:你说用了心跳机制,能具体说说怎么实现的吗?

: 好的,我来详细说说。

我们在用 WebSocket 做实时通信时,发现一个问题:客户端可能因为网络切换、App崩溃等原因断开连接,但服务器不知道,还一直维持着“僵尸连接”

为了解决这个问题,我们实现了心跳机制

具体做法是:

  1. 客户端每30秒向服务器发一个 ping 消息
  2. 服务器收到后,立即回一个 pong
  3. 服务器用 Netty 的 IdleStateHandler 检测:如果60秒内没收到任何消息(包括ping),就认为连接已断,主动关闭

这样就能及时清理无效连接,避免资源浪费和消息丢失。

我们还做了优化:

  • 心跳间隔支持动态调整,节省用户手机电量;
  • 用 Redis 管理连接状态,支持多台服务器集群部署;
  • 所有消息支持离线补推,确保不丢。

这套机制上线后,连接稳定性提升了90%以上,医生基本不会再“收不到新问诊”。


面试官:那如果医生很多,比如有5000个医生同时在线,一个服务器扛得住吗?

面试者: 扛不住啊! 一台服务器最多撑个三四千长连接,再多CPU和内存就顶不住了。

我们的解决办法是:加机器,搞集群

我们部署了3台Netty服务器,每台负责一部分医生的连接。

但新问题又来了:用户发起问诊时,怎么知道该把消息发给哪台服务器?

因为医生可能连在A机,也可能连在B机,系统得知道“张医生现在在几号服务器上”。

我们就用 Redis 来存一个“路由表”: doctor_1001 -> server_2 意思是医生1001连在2号服务器上。

用户一发起问诊,系统先查Redis,找到医生连的服务器,再通过内部网络把消息转发过去。

这样,就算有上万个医生在线,也能轻松扩展。


面试官:嗯,那医生点“接诊”的时候,怎么防止两个人同时接同一个单?

面试者: 这是个特别关键的点! 我们管这叫“接诊幂等性”和“防并发冲突”。

你想啊,两个医生同时看到一个问诊,手快有手慢无,但系统只能让一个人接。

我们是这么做的:

  1. 前端加防抖:医生点“接诊”按钮后,按钮立刻变灰,3秒内不能重复点。防止手抖连点。

  2. 后端加锁:真正处理接诊请求时,我们用Redis 分布式锁,锁住这个“问诊单ID”。

    • 第一个医生请求进来,拿到锁,开始处理:改状态为“已接诊”,记录医生ID,发通知。
    • 第二个医生几乎同时请求,拿不到锁,直接返回“已被接诊”。
  3. 数据库最终校验:就算前面都漏了,最后改数据库时,我们还加了乐观锁:

    sql
    UPDATE consultation SET status = 'accepted', doctor_id = 2002, version = version + 1 
    WHERE id = 5001 AND status = 'waiting' AND version = 3

    如果影响行数是0,说明已经被别人改了,接诊失败。

这套“前端防抖 + Redis锁 + 数据库乐观锁”三层保险,确保了永远不会一单一接


面试官:那接诊成功后,用户和医生开始聊天,消息怎么保证顺序和不丢?

面试者: 聊天这块我们也很重视。 用户发“我头疼三天了”,医生回“有没有发烧?”,结果消息乱序变成“有没有发烧?”在前,“我头疼”在后,那就乱套了。

所以我们做了几件事:

  1. 消息加序号:每条消息发出去时,带一个递增的msg_seq,比如1、2、3…… 客户端按序号排序显示,就算网络抖动导致后发的消息先到,也能正确排序。

  2. 消息存档:所有聊天记录都存到MySQL,表结构大概是:

    id | consultation_id | sender_id | receiver_id | content | msg_seq | send_time

    这样用户换手机、删App重装,聊天记录还能找回来。

  3. 已读未读:医生看完消息,点一下“已读”,我们就通过WebSocket推个“已读回执”给用户,提升体验。

  4. 离线推送:如果用户关了App,我们用极光推送(JPush) 发个通知:“医生回复您了”,点进去直接跳转到聊天页。


面试官:你刚才提到用了极光推送,能说说为啥用它吗?

: 是这样的,我们在做在线问诊时,有个需求是:即使用户关了App,也要能收到医生的回复提醒

如果只靠我们自己的WebSocket长连接,App一退,连接就断了,消息就推不到了。

所以我们引入了极光推送(JPush),它是一个第三方的移动推送服务。

简单说,它就像一个“通知中转站”: 当系统发现用户不在线时,就调它的API,把通知内容发给它,它再通过苹果或安卓的系统级通道,把消息推到用户手机上。

我们用它的主要原因有三个:

  1. 省事:不用自己对接苹果APNs和谷歌FCM,极光全包了;
  2. 稳定:支持离线推送、失败重试、多通道备份,消息到达率高;
  3. 有数据:能看到推送成功了多少、失败了多少,方便监控。

这样既保证了消息触达,又减少了我们自己的开发和运维成本。

面试官:听起来你们考虑得挺周全。那整个过程中,有没有用到消息队列?比如RabbitMQ这种?

面试者:用到了,而且用得挺关键的。

比如医生一接诊,系统要干一堆事:

  • 给用户发通知:“医生已接诊,请开始聊天”
  • 更新问诊状态
  • 记操作日志
  • 可能还要触发AI辅助诊断

如果这些都同步做,接诊响应就慢了。

我们就把这些“非核心但必须做的事”扔进 RabbitMQ

接诊成功后,只发一条消息到队列,比如:

json
{ "type": "consultation_accepted", "consultation_id": 5001, "doctor_id": 2002 }

然后有专门的消费者去处理:发通知、写日志、调AI…… 这样主流程就特别快,用户体验好。

而且万一短信服务挂了,消息还在队列里,等它恢复了继续处理,不会丢。


面试官:聊天数据是怎么保存的?用什么数据库?

: 我们是这样设计的:

首先,聊天数据属于核心业务数据,要求高可靠、强一致、可追溯,所以我们选择用 MySQL 来存储消息的元数据。

我们建了一张 chat_message 表,关键字段包括:问诊单ID、发送者、接收者、消息类型、内容、文件URL、消息序号、发送时间、已读状态。

sql
CREATE TABLE chat_message (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    consultation_id BIGINT NOT NULL COMMENT '问诊单ID,关联一次问诊',
    sender_id BIGINT NOT NULL COMMENT '发送者ID(用户或医生)',
    sender_role ENUM('patient', 'doctor') NOT NULL COMMENT '发送者角色',
    receiver_id BIGINT NOT NULL COMMENT '接收者ID',
    msg_type ENUM('text', 'image', 'prescription', 'notification') NOT NULL COMMENT '消息类型',
    content TEXT COMMENT '消息内容(文字或JSON格式)',
    file_url VARCHAR(500) COMMENT '图片/文件URL',
    msg_seq INT NOT NULL COMMENT '消息序号,保证顺序',
    send_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间',
    status ENUM('sent', 'delivered', 'read') DEFAULT 'sent' COMMENT '发送状态',
    
    INDEX idx_consultation (consultation_id, send_time),
    INDEX idx_user (sender_id, receiver_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • consultation_id:这次聊天属于哪个问诊单?比如“张三找李医生看病”这个单子。
  • sender_id / receiver_id:谁发的?发给谁?
  • msg_type:是文字?图片?还是系统通知?
  • content:如果是文字,就存文字;如果是图片,可以存描述,比如“伤口照片”。
  • file_url:图片或处方PDF的链接,比如 https://oss/xxx.jpg
  • msg_seq:消息序号,比如1、2、3……防止网络抖动导致消息乱序。
  • send_time:发送时间,用于排序。
  • status:已发送、已送达、已读,提升用户体验。

当用户或医生发一条消息时,流程如下:

  1. App 发送消息

    json
    { "type": "text", "content": "我头疼三天了", "consultation_id": 1001 }
  2. 服务器收到后

    • 先检查用户是否有权限发这条消息(是不是本次问诊的参与者);

    • 生成唯一 msg_seq(比如当前最大序号 + 1);

    • 如果是图片,先把文件上传到 OSS,拿到URL;

    • 插入数据库:

      sql
      INSERT INTO chat_message 
      (consultation_id, sender_id, sender_role, receiver_id, msg_type, content, file_url, msg_seq)
      VALUES (1001, 2001, 'patient', 3001, 'text', '我头疼三天了', NULL, 1);
  3. 消息推送

    • 通过 WebSocket 推送给医生;
    • 如果医生不在线,用 极光推送 发通知。
  4. 医生看到消息后

    • 客户端发一个“已读回执”;
    • 服务器更新 status = 'read'

其中:

  • 文本和结构化数据存在 content 字段;
  • 图片、处方PDF等大文件,我们会先上传到 阿里云OSS,然后把URL存到 file_url 字段,避免数据库膨胀;
  • msg_seq 序号保证消息顺序,防止网络抖动导致乱序;
  • 查询时按 consultation_id + send_time 做分页,支持上拉加载,每次加载20条数据。

另外,我们也考虑了性能和安全:

  • 用 Redis 缓存热点会话,减少数据库压力;
  • 所有医疗数据加密存储,访问有权限控制;
  • 支持合规审计和用户数据删除。

面试官:聊天数据量大了之后,查询会变慢,你们是怎么优化的?

: 是的,我们非常重视聊天查询的性能。随着数据增长,我们从四个层面做了优化:

  1. 索引优化:给 consultation_idsend_time 建了联合索引,确保按会话查消息时能高效走索引。
  2. 分页优化:不用 LIMIT OFFSET,改用“游标分页”,基于 send_timeid 进行下一页查询,避免深分页性能问题。
  3. Redis 缓存:把每个会话的最近50条消息缓存到 Redis,用户打开聊天页时优先走缓存,命中率90%以上,查询速度从100ms降到10ms内。
  4. 冷热分离:3个月前的历史消息自动归档到历史表,主表数据量控制在合理范围,保证热数据查询性能。

另外,我们也做了字段精简、读写分离、异步落盘等优化。

通过这些措施,即使一个医生有上万条历史消息,查最新消息依然是毫秒级响应,用户体验非常流畅。

面试官:你们的冷热数据迁移脚本是怎么设计的?怎么保证安全?

在“在线问诊”系统中:

  • 热数据:最近3个月的聊天记录 —— 用户经常查,访问频繁,必须快。
  • 冷数据:3个月前的聊天记录 —— 基本没人看,但又不能删(医疗合规要求保存),属于“归档数据”。

如果所有数据都放在一张表里,热数据的查询性能会越来越慢。

所以我们要把冷数据从主表迁移到历史表,减轻主表压力。

sql
-- 主表(热数据)
CREATE TABLE chat_message (
    id BIGINT PRIMARY KEY,
    consultation_id BIGINT NOT NULL,
    sender_id BIGINT,
    content TEXT,
    send_time DATETIME NOT NULL,
    INDEX idx_consultation_time (consultation_id, send_time),
    INDEX idx_send_time (send_time)
) ENGINE=InnoDB;

-- 历史表(冷数据)
CREATE TABLE chat_message_history LIKE chat_message;
-- 注意:历史表可以换存储引擎,比如 ARCHIVE,节省空间

我们设计了一个安全、可重复、低影响的迁移脚本,核心思路是:

  1. 按问诊单整体迁移:不是按时间直接删,而是先查出 consultation_id,确保一个会话的聊天记录完整迁移,不丢不乱。
  2. 小批量事务处理:每次只处理1000个问诊单,在一个事务中完成“插入历史表 + 删除主表”,保证原子性。
  3. 避免大事务:每批之间加1秒休眠,控制资源占用,防止锁表或IO打满。
  4. 凌晨执行:通过 Crontab 定时在凌晨2点运行,避开业务高峰期。
  5. 错误恢复机制:脚本有异常捕获和回滚,支持重复运行。迁移前也会做数据库备份。
  6. 监控与兜底:我们监控数据库性能指标,一旦异常立即停止。历史表结构和主表一致,未来需要还能查。

这套机制上线后,主表数据量减少了60%,聊天查询响应时间从平均80ms降到20ms以内,效果非常明显。