1. 内容整体设计与思路拆解
1.1 先搞清楚这套系统到底要解决什么问题
做老年人健康数据远程监控,我见过太多项目一上来就堆功能,心率要、血压要、血糖要、定位要、SOS按键要,最后做出来一个超级大杂烩,老人不会用、护理人员嫌麻烦、家属看不懂。实际上,这类系统的核心只有三件事:数据采得上、异常看得见、消息传得出去。
先说数据采得上。老年人的健康数据来源非常杂,最常见的是手环、血压计、血糖仪,有些还带睡眠监测和跌倒检测。这些设备有的走蓝牙,有的走WiFi,有的压根只有串口输出,而且各家协议千奇百怪——有的上报JSON,有的上报十六进制字节流,有的封了一层AES加密。把数据源统一接进来,是后端的第一个硬骨头。
再说异常看得见。数据采上来不是画两张折线图就完事的,核心价值在于异常识别。老人凌晨三点心率突然掉到40,白天血压冲到180,这些都不是“稍后看看报表”的事,是要立刻弹到值班大屏和家属手机上的。你得定义规则,得针对每个老人的历史情况做个性化基线,还得防止一天到晚瞎报警把人都搞得麻木掉。
最后是消息传得出去。这其实是最容易被低估的环节。老人戴着设备在家摔倒了,数据发到服务器了,结果值班室没人看大屏,家属手机没装App,那这套系统就白做了。我之前在一个社区项目里见过一种方案:预警不仅要推给家属App,还要给绑定的护理员发短信,同时在大屏上持续显示未处理的红点,如果五分钟没人确认就要二次升级到机构管理员。这种“多级触达”的设计,才是远程监控系统真正能救急的关键。
1.2 为什么选SpringBoot加Vue这套组合
这年头前后端框架多得是,但做这类健康管理系统,SpringBoot加Vue依然是性价比最高的选择。
后端用SpringBoot,理由很朴素:生态太成熟了。健康数据要存MySQL,它有现成的JPA或者MyBatis映射;设备上报要走HTTP接口,Controller写起来跟流水账一样简单;要接第三方短信通道,RestTemplate或者Hutool都顺手;后面万一要做AI分析,SpringBoot项目里集成Python的推理服务也方便。另外Spring Boot天然自带健康检查、指标监控和部署打包,对中小团队来说,配合Spring Cloud或者单机部署都能应付。
前端用Vue,是因为这类系统基本都是内部系统加家属端双形态。老人档案管理、设备绑定、告警规则配置这些都是典型的表单加列表页面,用Vue的组件化开发效率很高;家庭端需要在手机浏览器里随时查看,Vue配合Element Plus或者Vant写起来也很顺手。相比React那套JSX生态,Vue对国内团队来说上手快、中文资料多,招人、维护都省心。
这套组合还有一个隐藏优势:前后端完全分离。设备接入服务、告警推送服务、Web管理后台、家属查询接口,可以按模块拆开部署,被压垮了一部分不至于全盘瘫痪。想象一下,某个养老机构在用的系统对接了三百多台设备,每天早上统一上报数据的时候,接入服务可能会短暂冲高压,但这时候家属端的查询服务还得正常跑,不能因为数据上报卡顿就导致家属端也打不开页面。前后端分离天然就隔离了这种故障。
1.3 整体数据流设计
整个系统的数据流我习惯这么画,虽然不给部署图,但可以用文字描述清楚:
- 设备侧(手环/血压计/血糖仪)通过MQTT或HTTP把数据推给后端
- 后端先做格式解析和基础校验,然后把原始数据写入MySQL或者时序数据库
- 同时数据经过规则引擎处理,判断是否命中异常阈值
- 如果命中,产生一条告警记录,然后通过WebSocket实时推送给在线的前端页面
- 同时调用短信或微信模板消息通道,通知家属和值班人员
- 家属端和机构端都是Vue页面,通过REST接口查询历史数据和告警记录
- 管理员在后台维护老人档案、设备映射、告警阈值和值班人名单
这套链路的关键点在于:原始数据存储、告警判定、消息推送这三件事必须解耦。数据都先落库再说别的,哪怕告警引擎挂了,数据还在,后续还能补算;推送通道失败了,告警记录里标记一个“待重试”状态,后面定时任务再扫一遍,不会丢了关键事件。
2. 核心细节解析与实操要点
2.1 设备接入层怎么设计
设备接入是整个系统最容易出Bug的地方,没有之一。我建议在第一版就做一层统一的接入抽象,别管你手里现在有几台设备,后面一定还会加新的。
后端定义一个设备上报消息的统一结构:
public class DeviceReportMessage { private String deviceSn; // 设备序列号 private String deviceType; // 设备类型:wristband/sphygmomanometer/glucometer private String dataType; // 数据类型:heartRate/bloodPressure/bloodSugar private Map<String, Double> values; // key-value形式,例如 {"heartRate": 72} private Long measuredAt; // 设备端测量时间戳(毫秒) private Long receivedAt; // 服务端接收时间戳 }不管设备是走HTTP还是MQTT,最终都先转换成这个结构,再进入后续逻辑。
这里有两个非常重要的细节,我吃了亏才记住的:
第一,测量时间一定要用设备端的时间戳。很多设备上报的时候根本没带测量时间,服务端就默认用接收时间。但这会导致一个问题:如果某个设备断网了,恢复联网后会把离线期间缓存的几十条数据一股脑上传,这时候如果全按接收时间入库,历史曲线就会变得特别诡异——明明凌晨没有心跳记录,打开报表却看到一条凌晨的数据出现在上午九点。所以必须在接入层做一次“时间兜底”:设备没带时间戳的,服务端补一个接收时间,但要在记录里打上标记,方便后面排查数据质量问题。
第二,数值的边界要做清洗。我见过心率300多的血压计数据,也见过血糖值为0的“健康人”记录,这些基本都是设备端信号干扰或传感器异常导致的脏数据。接入层可以做最简单的规则过滤:心率范围30~220,收缩压70~200,舒张压40~120,超出直接丢弃但不报错——因为报错会影响后续数据的连续性,静默丢弃更好。同时保留一条原始报文日志,方便后面做数据分析时追溯。
2.2 告警规则引擎
大多数人做告警,第一反应是写死一个全局阈值:心率大于100报警。实际上这么做是要被现场骂死的。我参与过一个项目的真实情况——有一位老人常年心率就在55左右,身体很正常,结果有一天早晨心跳到了85,系统直接判为“心动过速”报警,把值班护士折腾了一趟,结果是老人早上去晨练了。反过来说,一个平时跑步的人心率日常就90多,阈值设到100对他来说根本不敏感。
所以告警规则应该分两层:
第一层是全局规则,针对量化合理的生理范围做托底:
| 数据类型 | 低阈值 | 高阈值 | 说明 |
|---|---|---|---|
| 心率 | 40 bpm | 130 bpm | 老年人静止心率不宜低于40或持续高于130 |
| 收缩压 | 80 mmHg | 180 mmHg | 收缩压过高属于高危状态 |
| 舒张压 | 45 mmHg | 110 mmHg | 配合收缩压判定,单纯一项偏高不急着报 |
| 血氧 | 90% | — | 低于90%要重点告警 |
| 血糖 | 3.8 mmol/L | 11.1 mmol/L | 空腹和餐后参考值不同,需按时间区分 |
第二层是个性化基线。系统给每个老人生成一份健康画像,取最近30天同一时段(比如早上6点到9点)数据的P5到P95分位数作为正常区间,一旦某次测量超出这个区间,再叠加连续次数判断。比如血压,不是第一次高就报警,而是半小时内连续三次高才有动作。这个逻辑其实就一句话:报警要“慢半拍”,但一旦报了就要“咬住不放”。
2.3 推送链路:WebSocket加多级兜底
前端页面要实时看到新数据、新告警,常规轮询虽然简单,但体验不够好、压力也大。我当时用了WebSocket做实时通道,服务端用Spring的WebSocket原生支持,没有引入额外的框架,项目本身不大,直接用注解加拦截器做鉴权就够。
推送的关键在于消息格式一定要带类型,前端才能区分处理:
{ "type": "HEALTH_ALERT", "payload": { "alertId": 12893, "deviceSn": "WRISTBAND-20231001-001", "elderlyName": "张大爷", "dataType": "HEART_RATE", "value": 42, "measuredAt": 1696413600000, "level": "URGENT" } }除了WebSocket,系统还应该有短信通道、电话语音通知通道。不同级别的告警走不同的通道:普通异常只在系统内记录,家属端App弹一条消息;严重异常要短信通知家属和值班员;高危异常(比如跌倒检测触发)要自动呼叫绑定电话。这个多级兜底的意义在于,哪怕值班人员不在大屏前,系统也能用最原始的方式把信息送到人那里。
2.4 前端可视化选型
前端图表我用的ECharts,说实话在这个场景里它比任何商业图表库都靠谱。老人健康数据本身就是时间序列折线图为主,心率和血压的走势、血糖的日分布、睡眠分时段统计,这些图表ECharts都有现成的配置项。前端页面主要分三块:
- 机构大屏:展示所有老人的实时状态总览,用颜色区分正常、关注、警告,值班人员扫一眼就能判断今天有没有异常。
- 家属端:只展示绑定老人的数据和告警,用手机浏览器访问,所以页面要适配移动端,卡片式设计居多,图表要能自适应宽度。
- 管理端:负责老人档案维护、设备绑定、阈值配置、值班人员名单管理。
ECharts有一点要注意:大量实时更新时不要直接setOption整个替换,性能会很差。更好的做法是维护一个环形缓存区,用appendData的方式增量追加数据,或者每五秒拉取一次最新数据做一次平滑更新。我见过一个项目直接把每秒钟的推送数据塞进图表,结果浏览器卡到无法点击菜单,还得重启页面。
3. 实操过程与核心环节实现
3.1 数据库设计笔记
设计表的思路是:老人档案、设备信息、健康数据、告警记录、处理记录,这五张表打底。
老人档案表(elderly_profile)的核心字段:
CREATE TABLE elderly_profile ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT '老人姓名', gender TINYINT NOT NULL COMMENT '1男 2女', age INT NOT NULL, room_no VARCHAR(32) COMMENT '房间号', contact_name VARCHAR(32) COMMENT '紧急联系人', contact_phone VARCHAR(20) COMMENT '紧急联系电话', medical_history TEXT COMMENT '既往病史摘要', status TINYINT DEFAULT 1 COMMENT '1正常 0停用', remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人档案表';健康数据表是数据量最大的表,我建议直接按天分区或者按月归档。字段设计上要保留原始值和修正值的区分,方便数据溯源:
CREATE TABLE health_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, elderly_id BIGINT NOT NULL COMMENT '老人id', device_sn VARCHAR(64) NOT NULL COMMENT '设备序列号', data_type VARCHAR(32) NOT NULL COMMENT 'heartRate/bloodPressure/bloodSugar/bloodOxygen', value_json VARCHAR(512) NOT NULL COMMENT '指标值,如{"systolic":135,"diastolic":85}', measured_at BIGINT NOT NULL COMMENT '测量时间毫秒时间戳', received_at BIGINT NOT NULL COMMENT '接收时间毫秒时间戳', source_flag TINYINT DEFAULT 1 COMMENT '1设备上报 2手工录入 3离线补传', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康数据流水表';索引上我踩过坑,复合索引必须带上elderly_id和measured_at,否则只要老人数量过百、数据量过万,查询历史曲线就会变得非常慢:
ALTER TABLE health_record ADD INDEX idx_elderly_time (elderly_id, measured_at);告警记录表要带上处理状态,不然运营人员不知道哪些告警需要跟进:
CREATE TABLE health_alert ( id BIGINT PRIMARY KEY AUTO_INCREMENT, elderly_id BIGINT NOT NULL, alert_type VARCHAR(32) NOT NULL COMMENT 'HEART_RATE_ABNORMAL等', alert_level VARCHAR(16) NOT NULL COMMENT 'URGENT/HIGH/MEDIUM/LOW', alert_content VARCHAR(255) NOT NULL, measured_at BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待处理 1已确认 2已处理 3误报', handler_name VARCHAR(32) COMMENT '处理人', handle_remark VARCHAR(255) COMMENT '处理意见', handle_time DATETIME COMMENT '处理时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康告警记录表';3.2 后端接口实现要点
整个后端我习惯按“Controller -> Service -> Repository”三层来写。下面是几个关键接口的代码要点。
设备数据上报接口,接收POST请求,使用Map接收动态字段:
@PostMapping("/api/v1/device/report") public Result reportDeviceData(@RequestBody DeviceReportMessage message) { // 1. 校验签名或者Token if (!deviceAuthService.verifyToken(message.getDeviceSn())) { return Result.error(401, "设备认证失败"); } // 2. 基础清洗 DataCleanResult cleanResult = dataCleanService.clean(message); if (!cleanResult.isValid()) { return Result.success(); // 静默丢弃,返回成功避免设备重传 } // 3. 落库 healthRecordService.save(cleanResult.getRecord()); // 4. 触发规则引擎 alertEngine.evaluate(cleanResult.getRecord()); return Result.success(); }注意第2步,静默丢弃这个行为一定只针对明显异常数据,而且必须详细记录日志。因为后面如果你发现某个时段的数据曲线总是出现缺口,你需要能查出来到底是设备没上报还是服务端过滤掉了。
告警规则引擎的核心代码,用策略模式实现:
@Component public class HeartRateAlertRule implements AlertRule { @Override public AlertResult evaluate(HealthRecord record) { if (!"heartRate".equals(record.getDataType())) { return null; } double value = record.getValue(); // 个性化阈值从Redis缓存取,没有则用全局默认值 Threshold threshold = thresholdService.getThreshold(record.getElderlyId(), "heartRate"); if (value < threshold.getMin() || value > threshold.getMax()) { return AlertResult.builder() .level(value < 40 ? AlertLevel.URGENT : AlertLevel.HIGH) .message(String.format("心率异常,当前值为%.0f次/分", value)) .build(); } return null; } }这里说明一下为什么个性化阈值要先查Redis:因为一个护理人员可能同时要看几十个老人,每个老人的告警判断都会查数据库的话,数据库压力很大。Redis缓存的话,把每个老人+每个指标的阈值做成一个key,TTL设成和定时重算周期一致。
3.3 前端核心代码片段
下面摘两个最核心的前端模块。
实时数据接收模块,用WebSocket连接后端,做好断线重连:
class HeartbeatSocket { constructor() { this.ws = null this.heartbeatInterval = null this.reconnectTimer = null this.init() } init() { this.ws = new WebSocket(`ws://${location.host}/ws/monitor`) this.ws.onopen = () => { // 发送鉴权信息 this.ws.send(JSON.stringify({ type: 'AUTH', token: this.getToken() })) this.startHeartbeat() } this.ws.onmessage = (event) => { const data = JSON.parse(event.data) this.dispatch(data) } this.ws.onclose = () => { this.reconnect() } } startHeartbeat() { this.heartbeatInterval = setInterval(() => { this.ws.send(JSON.stringify({ type: 'PING' })) }, 10000) } reconnect() { if (this.reconnectTimer) return this.reconnectTimer = setTimeout(() => { this.init() this.reconnectTimer = null }, 3000) } }ECharts图表更新模块,增量更新避免性能问题:
export function updateChart(chart, newData) { // 用当前时间作为x轴 const xAxisTime = new Date().toLocaleTimeString() const series = chart.getOption().series series[0].data.push({ value: newData.value, name: xAxisTime }) // 清除超过500个点的旧数据 if (series[0].data.length > 500) { series[0].data.shift() } chart.setOption({ series }) }对于心率这种实时数据,我强烈建议前端不要用折线图直接展示每一秒的数据点,最好先做一分钟聚合,显示均值或区间带。一个是视觉上更平滑,一个是不会让折线图变成一条无法阅读的锯齿线。
3.4 部署与配置
前后端是分离部署的。SpringBoot打成一个jar包跑在服务器上,Vue构建后的dist目录用Nginx托管,同时Nginx负责反向代理/api路径到SpringBoot的端口。
Nginx配置的关键部分:
server { listen 80; server_name monitor.example.com; # 前端静态资源 location / { root /opt/health-monitor/dist; try_files $uri $uri/ /index.html; } # 后端API代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # WebSocket代理 location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; } }WebSocket的代理头非常重要,少写一个Upgrade头就会导致前端能连上但收不到推送,排查起来很抓狂。我第一次部署时就是漏了这两行,内网调试时WebSocket工作正常,一旦走到Nginx后面就断了。
3.5 数据上报频率与缓存
设备上报频率我建议做成可配置的。不同数据的采集频率不一样:心率手环可以每5分钟上报一次,但关键时段(比如凌晨)要加速到每分钟一次;血压计这种一天也就量两三次,没必要高频。
后端方面,内存里维护一个最近上报记录的缓存,用来做重复数据判断。有时候设备端会出现重复推送同一条数据的情况,如果不做去重,数据库里就会出现两条一样的记录,折线图上体现为数据突变,而且告警也会重复触发。
去重的逻辑很简单:以deviceSn+dataType+measuredAt作为唯一键,在Redis里设置一个短过期时间的占位标记。重复上报时发现已经有这个key,就丢弃。
4. 常见问题与排查技巧实录
4.1 高概率踩坑汇总
我把开发、调试、上线过程中遇到过的问题整理成一张表,遇到类似的可以直接对着查:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 设备数据上报成功但表格里没数据 | 清洗规则误判,数据被静默丢弃 | 查清洗日志,放宽数值边界;确认设备端时间戳是否正常 |
| 前端图表曲线出现重影或跳变 | 同一时间戳重复入库 | 确认Redis去重key是否生效,检查设备是否重复推送 |
| WebSocket连上后收不到实时数据 | Nginx忘了配WebSocket代理头 | 检查Upgrade和Connection头,用浏览器Network面板看握手请求 |
| 告警频繁出现误报 | 个性化基线没有生效 | 检查阈值缓存是否过期,Redis key拼写是否正确 |
| 早高峰时段接口响应变慢 | 数据批量上传,MySQL写入有压力 | 开启批量插入,写库走队列异步落库,前端查询走只读副本 |
| 折线图数据开始不久就卡顿 | ECharts数据点过多,没有做聚合 | 改成分钟聚合显示,或使用dataZoom和采样 |
| 手机端页面样式错乱 | Vue响应式布局没适配好 | 用移动端UI组件库(如Vant),宽度用百分比或rem |
4.2 消息风暴问题
上线后最容易出现的一个全局性问题就是告警风暴。举个例子,某天凌晨供电波动,一批手环同时失去信号,然后恢复连接后一起补传数据,导致规则引擎在短时间内生成了几百条告警,值班人员的短信被刷爆。
这种问题从根上要解决,我提供三个策略:
第一,告警静默期。同一个老人的同一类告警,如果在五分钟内已经触发过一次,就不再向短信通道推送,只在系统内累积记录。前端页面能实时看到,短信通道却不会重复轰炸。
第二,批量补传降级处理。检测到某个设备断线后恢复,恢复后补传的数据要单独标识,可以先入库但不参与实时告警判定,等后台批量任务重新计算,避免几百条数据同时冲击规则引擎。
第三,告警收敛通知。真有大规模数据异常时,后端统一生成一条汇总告警:“设备网络波动,12位老人中有30条异常记录待复核”,按批次通知值班人员,而不是一条条通知。
4.3 数据质量问题不能迷信设备
这一点我想单独拿出来说。设备上报的数据不一定是对的,设备端传感器受佩戴松紧、皮肤湿度、运动状态影响极大。我见过最离谱的一条数据:一位老人在散步时手环心率显示185,如果直接触发告警,会把家属吓得半死,实际上只是手环没戴紧,在手臂上晃动导致的伪信号。
务必要在系统上做的事情:
- 在告警记录里加上一条**“疑似伪差”备注**,当同一老人短时间内连续出现大幅波动且没有运动标签时,值班人员就要怀疑是设备佩戴问题。
- 在展板页面上不要只显示单个测量值,要带上历史趋势。单独的180/110看起来很吓人,如果过去30天都在130/80上下稳定波动,这次更像是测量误差;如果过去一周一路走高,那才需要紧急干预。
4.4 权限设计不能马虎
这类系统涉及敏感的健康数据,权限一定要按角色严格划分。系统里至少要保证三种角色:
| 角色 | 可见范围 | 操作权限 |
|---|---|---|
| 系统管理员 | 所有老人、所有设备、全部告警 | 配置阈值、绑定设备、管理账号 |
| 护理人员 | 负责分区的老人数据 | 确认告警、录入随访记录 |
| 家属 | 只绑定自家老人 | 查看数据、接收告警通知、不能改配置 |
权限是后端接口校验,不能只在前端做按钮隐藏。我接手过一个项目,前端把管理员的按钮隐藏了,但后端接口根本没做权限校验,随便改一下请求参数就能看到其他老人的健康数据。这种问题一旦发生,对系统信任度的打击是毁灭性的。
健康数据的权限校验有一点要注意:家属和老人之间是多对一还是多对多。常见情况是多个子女同时关注一位老人,所以绑定关系表要做成关联表,不能简单在老人档案表里存一个家属ID。
5. 扩展方向与进阶打算
整个系统跑起来之后,还有两条比较实际的优化路径,顺手提一下。
第一条是接入时序数据库。当老人数量和上报频率逐步提高,MySQL单表写压力会越来越大。这时可以把原始数据流水迁移到时序数据库(比如TDengine或IotDB),MySQL只保留近期热点数据和业务数据。时序库在时间范围查询和聚合统计上的能力比MySQL强不少,存储和查询效率也能提上来。不过这个改动会影响数据接入层和图表查询层,架构上要做好兼容设计。
第二条是结合简单模型做健康风险预测。目前还处于“超阈值就报警”的阶段,长期来看可以积累每位老人的历史数据,用简单的线性回归或者LSTM模型预测未来几小时的心率走势,提前发现潜在风险。这个方向不需要一开始就做得很重,先从单指标预测开始,预测准确率稳定了再考虑多指标融合。
6. 一些实际经验与建议
最后分享两点我觉得最值得记录的经验。
第一点是,做这类系统,一定要先去现场待两天再写代码。我在开发初期犯过最大的错误就是坐在办公室里对着需求文档敲代码,觉得心率报警阈值设成90挺合理,结果到现场才发现,那家机构的老人普遍有心脏基础病,60到90才是正常区间,低于60才是需要关注的人群。你不去看真实的数据分布、不跟值班护士聊一次,做出来的规则可能就是反的。
第二点是,告警不只是技术问题,更是“信任问题”。系统第一次误报,家属可能还会理解;十次里八次是误报,下次真出了问题,家属反而觉得又是系统搞错了。所以在这个项目里,宁可少告警,也尽量不要让“狼来了”的故事反复发生。这就要求告警规则里每一个阈值、每一个连续次数、每一条消息文案,都要经过现场实际运行的验证再固化下来。
这套系统做完,最大的成就感不是接口写得多么优雅、页面做得多好看,而是看到家属列表里的“今日数据正常”,或者深夜收到一条真正有意义的告警后,护理人员真的在几分钟内赶到了老人身边。做技术的人容易陷进框架和代码里拔不出来,但这类系统的价值最终是落在它能不能帮到那些人。能想明白这一点,再来回看当初踩过的坑,都是值得的。