面试官:你刚才说的负责过“在线接诊”这个功能模块,那么这个模块具体是干啥的?你能说说你负责了哪些部分吗?
面试者: 没问题。 “在线接诊”简单说就是:用户点一下“找医生”,系统把消息推给医生,医生点“接诊”,然后俩人就能开始聊天了。 听起来简单,但其实背后要处理很多事。 我主要负责了三块:
- 用户发起问诊——比如选科室、描述病情、上传图片这些;
- 医生端的接诊通知和抢单逻辑——怎么让医生第一时间知道有新问诊,怎么防止多个医生同时接同一个单;
- 接诊后的实时聊天功能——用户和医生能发文字、图片,消息要秒到,不能丢。
其中最难、也最核心的,就是消息怎么实时推给医生,还有接诊那一刻不能出错,比如两个医生同时点“接诊”,结果都接到了,那肯定不行。
面试官:嗯,那你说“消息要实时推给医生”,你们是怎么做的?总不能让用户一直刷新页面吧?
面试者: 哈哈,确实不能刷新。 最开始我们想过“轮询”——就是让医生的手机每2秒问一次“有没有新问诊?”,但这样太耗电、耗流量,服务器压力也大,一条消息可能延迟好几秒,用户体验很差。
后来我们就改用了一种叫 WebSocket 的技术,它能建立一个“长连接”。 简单说,就像医生打开App的时候,就跟我们服务器打了个“电话”,这个电话一直挂着,不挂断。
一旦有新用户发起问诊,服务器就通过这个“电话线”,立刻把消息推过去,医生手机“叮”一下就收到了,基本是秒级的。
我们用的是 Netty 这个框架来搭这个通信网关,因为它处理高并发特别强,撑得住大量医生同时在线。
面试官:听起来不错。但你说“电话一直挂着”,那万一线断了呢?比如医生手机切到后台,或者网络不好,消息会不会丢?
面试者: 问得太准了!这正是我们踩过的大坑。
刚开始上线那会儿,真有医生反馈:“我明明在线,怎么没收到新问诊?” 我们一查日志,发现是连接断了,但系统没及时发现,导致消息发不出去。
后来我们发现,光靠WebSocket不行,还得加“心跳机制”。
啥是心跳? 就是每隔30秒,服务器和手机互相发个“你还活着吗?”的小消息。 如果连续两次没收到回复,我们就认为“这人掉线了”,赶紧把连接关掉,避免资源浪费。
但问题又来了:连接断了,新问诊消息怎么办?
我们想了两个办法:
- 消息存数据库:所有问诊请求,不管有没有推成功,都先存到MySQL里,标记状态。
- 医生上线后主动查:医生重新打开App时,会主动问服务器:“我有没有漏掉的问诊?”系统就从数据库里把没推送成功的找出来,补发一遍。
这样就做到了“消息不丢,最终能收到”。
面试官:你说用了心跳机制,能具体说说怎么实现的吗?
你: 好的,我来详细说说。
我们在用 WebSocket 做实时通信时,发现一个问题:客户端可能因为网络切换、App崩溃等原因断开连接,但服务器不知道,还一直维持着“僵尸连接”。
为了解决这个问题,我们实现了心跳机制。
具体做法是:
- 客户端每30秒向服务器发一个 ping 消息;
- 服务器收到后,立即回一个 pong;
- 服务器用 Netty 的 IdleStateHandler 检测:如果60秒内没收到任何消息(包括ping),就认为连接已断,主动关闭。
这样就能及时清理无效连接,避免资源浪费和消息丢失。
我们还做了优化:
- 心跳间隔支持动态调整,节省用户手机电量;
- 用 Redis 管理连接状态,支持多台服务器集群部署;
- 所有消息支持离线补推,确保不丢。
这套机制上线后,连接稳定性提升了90%以上,医生基本不会再“收不到新问诊”。
面试官:那如果医生很多,比如有5000个医生同时在线,一个服务器扛得住吗?
面试者: 扛不住啊! 一台服务器最多撑个三四千长连接,再多CPU和内存就顶不住了。
我们的解决办法是:加机器,搞集群。
我们部署了3台Netty服务器,每台负责一部分医生的连接。
但新问题又来了:用户发起问诊时,怎么知道该把消息发给哪台服务器?
因为医生可能连在A机,也可能连在B机,系统得知道“张医生现在在几号服务器上”。
我们就用 Redis 来存一个“路由表”: doctor_1001 -> server_2 意思是医生1001连在2号服务器上。
用户一发起问诊,系统先查Redis,找到医生连的服务器,再通过内部网络把消息转发过去。
这样,就算有上万个医生在线,也能轻松扩展。
面试官:嗯,那医生点“接诊”的时候,怎么防止两个人同时接同一个单?
面试者: 这是个特别关键的点! 我们管这叫“接诊幂等性”和“防并发冲突”。
你想啊,两个医生同时看到一个问诊,手快有手慢无,但系统只能让一个人接。
我们是这么做的:
前端加防抖:医生点“接诊”按钮后,按钮立刻变灰,3秒内不能重复点。防止手抖连点。
后端加锁:真正处理接诊请求时,我们用Redis 分布式锁,锁住这个“问诊单ID”。
- 第一个医生请求进来,拿到锁,开始处理:改状态为“已接诊”,记录医生ID,发通知。
- 第二个医生几乎同时请求,拿不到锁,直接返回“已被接诊”。
数据库最终校验:就算前面都漏了,最后改数据库时,我们还加了乐观锁:
sqlUPDATE consultation SET status = 'accepted', doctor_id = 2002, version = version + 1 WHERE id = 5001 AND status = 'waiting' AND version = 3如果影响行数是0,说明已经被别人改了,接诊失败。
这套“前端防抖 + Redis锁 + 数据库乐观锁”三层保险,确保了永远不会一单一接。
面试官:那接诊成功后,用户和医生开始聊天,消息怎么保证顺序和不丢?
面试者: 聊天这块我们也很重视。 用户发“我头疼三天了”,医生回“有没有发烧?”,结果消息乱序变成“有没有发烧?”在前,“我头疼”在后,那就乱套了。
所以我们做了几件事:
消息加序号:每条消息发出去时,带一个递增的
msg_seq,比如1、2、3…… 客户端按序号排序显示,就算网络抖动导致后发的消息先到,也能正确排序。消息存档:所有聊天记录都存到MySQL,表结构大概是:
id | consultation_id | sender_id | receiver_id | content | msg_seq | send_time这样用户换手机、删App重装,聊天记录还能找回来。
已读未读:医生看完消息,点一下“已读”,我们就通过WebSocket推个“已读回执”给用户,提升体验。
离线推送:如果用户关了App,我们用极光推送(JPush) 发个通知:“医生回复您了”,点进去直接跳转到聊天页。
面试官:你刚才提到用了极光推送,能说说为啥用它吗?
你: 是这样的,我们在做在线问诊时,有个需求是:即使用户关了App,也要能收到医生的回复提醒。
如果只靠我们自己的WebSocket长连接,App一退,连接就断了,消息就推不到了。
所以我们引入了极光推送(JPush),它是一个第三方的移动推送服务。
简单说,它就像一个“通知中转站”: 当系统发现用户不在线时,就调它的API,把通知内容发给它,它再通过苹果或安卓的系统级通道,把消息推到用户手机上。
我们用它的主要原因有三个:
- 省事:不用自己对接苹果APNs和谷歌FCM,极光全包了;
- 稳定:支持离线推送、失败重试、多通道备份,消息到达率高;
- 有数据:能看到推送成功了多少、失败了多少,方便监控。
这样既保证了消息触达,又减少了我们自己的开发和运维成本。
面试官:听起来你们考虑得挺周全。那整个过程中,有没有用到消息队列?比如RabbitMQ这种?
面试者:用到了,而且用得挺关键的。
比如医生一接诊,系统要干一堆事:
- 给用户发通知:“医生已接诊,请开始聊天”
- 更新问诊状态
- 记操作日志
- 可能还要触发AI辅助诊断
如果这些都同步做,接诊响应就慢了。
我们就把这些“非核心但必须做的事”扔进 RabbitMQ。
接诊成功后,只发一条消息到队列,比如:
{ "type": "consultation_accepted", "consultation_id": 5001, "doctor_id": 2002 }然后有专门的消费者去处理:发通知、写日志、调AI…… 这样主流程就特别快,用户体验好。
而且万一短信服务挂了,消息还在队列里,等它恢复了继续处理,不会丢。
面试官:聊天数据是怎么保存的?用什么数据库?
你: 我们是这样设计的:
首先,聊天数据属于核心业务数据,要求高可靠、强一致、可追溯,所以我们选择用 MySQL 来存储消息的元数据。
我们建了一张 chat_message 表,关键字段包括:问诊单ID、发送者、接收者、消息类型、内容、文件URL、消息序号、发送时间、已读状态。
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:已发送、已送达、已读,提升用户体验。
当用户或医生发一条消息时,流程如下:
App 发送消息:
json{ "type": "text", "content": "我头疼三天了", "consultation_id": 1001 }服务器收到后:
先检查用户是否有权限发这条消息(是不是本次问诊的参与者);
生成唯一
msg_seq(比如当前最大序号 + 1);如果是图片,先把文件上传到 OSS,拿到URL;
插入数据库:
sqlINSERT 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);
消息推送:
- 通过 WebSocket 推送给医生;
- 如果医生不在线,用 极光推送 发通知。
医生看到消息后:
- 客户端发一个“已读回执”;
- 服务器更新
status = 'read'。
其中:
- 文本和结构化数据存在
content字段; - 图片、处方PDF等大文件,我们会先上传到 阿里云OSS,然后把URL存到
file_url字段,避免数据库膨胀; - 用
msg_seq序号保证消息顺序,防止网络抖动导致乱序; - 查询时按
consultation_id+send_time做分页,支持上拉加载,每次加载20条数据。
另外,我们也考虑了性能和安全:
- 用 Redis 缓存热点会话,减少数据库压力;
- 所有医疗数据加密存储,访问有权限控制;
- 支持合规审计和用户数据删除。
面试官:聊天数据量大了之后,查询会变慢,你们是怎么优化的?
你: 是的,我们非常重视聊天查询的性能。随着数据增长,我们从四个层面做了优化:
- 索引优化:给
consultation_id和send_time建了联合索引,确保按会话查消息时能高效走索引。 - 分页优化:不用
LIMIT OFFSET,改用“游标分页”,基于send_time和id进行下一页查询,避免深分页性能问题。 - Redis 缓存:把每个会话的最近50条消息缓存到 Redis,用户打开聊天页时优先走缓存,命中率90%以上,查询速度从100ms降到10ms内。
- 冷热分离:3个月前的历史消息自动归档到历史表,主表数据量控制在合理范围,保证热数据查询性能。
另外,我们也做了字段精简、读写分离、异步落盘等优化。
通过这些措施,即使一个医生有上万条历史消息,查最新消息依然是毫秒级响应,用户体验非常流畅。
面试官:你们的冷热数据迁移脚本是怎么设计的?怎么保证安全?
你:
在“在线问诊”系统中:
- 热数据:最近3个月的聊天记录 —— 用户经常查,访问频繁,必须快。
- 冷数据:3个月前的聊天记录 —— 基本没人看,但又不能删(医疗合规要求保存),属于“归档数据”。
如果所有数据都放在一张表里,热数据的查询性能会越来越慢。
所以我们要把冷数据从主表迁移到历史表,减轻主表压力。
-- 主表(热数据)
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,节省空间我们设计了一个安全、可重复、低影响的迁移脚本,核心思路是:
- 按问诊单整体迁移:不是按时间直接删,而是先查出
consultation_id,确保一个会话的聊天记录完整迁移,不丢不乱。 - 小批量事务处理:每次只处理1000个问诊单,在一个事务中完成“插入历史表 + 删除主表”,保证原子性。
- 避免大事务:每批之间加1秒休眠,控制资源占用,防止锁表或IO打满。
- 凌晨执行:通过 Crontab 定时在凌晨2点运行,避开业务高峰期。
- 错误恢复机制:脚本有异常捕获和回滚,支持重复运行。迁移前也会做数据库备份。
- 监控与兜底:我们监控数据库性能指标,一旦异常立即停止。历史表结构和主表一致,未来需要还能查。
这套机制上线后,主表数据量减少了60%,聊天查询响应时间从平均80ms降到20ms以内,效果非常明显。