尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot+Vue3+MyBatis构建社区智慧养老监护平台:实战与踩坑记录

SpringBoot+Vue3+MyBatis构建社区智慧养老监护平台:实战与踩坑记录 你见过社区养老服务站的后台吗我之前也没见过直到表妹在社区工作有次去她办公室等她下班才发现整个值班台堆满了纸质台账某某老人今早没来食堂、某某老人昨晚十一点起夜了三次、某某老人的手环已经三天没同步数据。每一件事都靠值班员手工登记等真正发现异常的时候问题往往已经过去大半天了。这就是做社区智慧养老监护管理平台的直接动机。这个项目后端采用 Java SpringBoot MyBatis前端用 Vue3数据库用的 MySQL整体是前后端分离架构。目标是让社区里分散的老人档案、智能监护设备、告警记录、护工任务在一个系统里跑通而不是继续躺在 Excel 和纸质本子上。如果你也在接养老类项目或者正想找一个前后端分离的完整业务系统练手这篇文章可以给你一条能直接照着走的路线。1. 这个项目最初是怎么落地的背景、选型与架构演进1.1 社区养老场景到底缺什么养老监护这件事表面看是给老人戴个智能手环、装个床垫传感器这么简单真正做下去才发现社区里缺的从来不是数据而是把数据变成行动的那条链路。社区里分别有四类人诉求完全不一样老人自己希望不舒服的时候有人能快速知道但又不想时刻被监视。家属最想知道的是父母现在安全吗可他们不在现场只能靠打电话。护工/社工每天要巡房、填表、上报异常这些工作几乎都是手工的重复又容易漏。社区管理员要盯着几十上百位老人的状态还要应付上级对记录、报表的检查压力全在值班台。常见的现实情况是手环的数据在厂商自己的手机 App 里智能床垫的数据在另一个网页后台里社区里的视频监控又是独立的系统。等到需要核查某位老人某天晚上的异常情况时值班员要同时打开四个平台去翻记录。所以这个项目在立项时定了一个原则不做一个看起来很智能的中控台而是做一个能把老人档案、设备上报、告警事件、工单处理串在一起的业务闭环系统。设备厂商的差异先不管只要数据能按统一格式进来剩下的事情平台自己消化。1.2 为什么选了 SpringBootVue3MyBatis 这套组合说实话这套技术栈在 2025 年不算最新但它是这类社区级项目里最不容易翻车的组合原因很实在。后端用 SpringBoot理由几乎不用争。它内嵌 Tomcat不需要单独配容器配置文件集中生态里要什么有什么。这里有个选型细节想提醒一下SpringBoot 3.x 要求 JDK 17 及以上而如果你团队习惯的是 Java 8 老生态选 2.7.x 会更稳。我当时就是在这上面纠结了一下后来被同事劝住生产环境老老实实用了 SpringBoot 2.7.x有些经过验证的第三方库直接就能用不用为版本兼容折腾。持久层在 MyBatis 和 JPA 之间我选了 MyBatis。这类系统的报表查询、多表联查非常多而且后期接了不同厂商的设备之后SQL 大概率要在 XML 里反复调整。MyBatis 的 XML 映射在复杂查询时是可控的SQL 是自己一行行写的出问题一眼就能看出来。JPA 在简单 CRUD 上确实爽但联表多了之后生成的 SQL 会变成一个黑盒出了问题排查成本反而高。前端选 Vue3 也很明确。Vue 的生态在后台管理系统这个领域成熟度很高组合式 API 的状态组织能力比 Options API 清晰得多。配合 Element Plus 的组件库写一个带表单、表格、弹窗的管理后台速度非常快。而且 Vue3 的 TypeScript 支持很自然团队里愿意写类型的写类型不愿意的也能渐进式接入卡得不会太死。数据库选了 MySQL 而不是更大型的数据库是因为这类社区项目的量级其实有限老人档案几千到几万条设备上报数据量虽然大一些但也在 MySQL 能扛的范围内。MySQL 本身部署和运维成本低、社区文档多对预算有限的街道或社区来说是最现实的选择。1.3 前后端分离的模块边界划分做前后端分离最容易犯的错是把分离理解成拆开结果接口设计得很随意前端拼数据拼到哭。这个项目在动工前先把模块边界画清楚了让两边开发各自照着自己的边界走。整个系统拆成三大端管理后台PC 端社区管理员和护工使用负责老人档案维护、设备绑定、告警查看、工单处理、统计报表。设备接入端服务端接收模拟智能手环、床垫、SOS 按钮的 HTTP 上报同时支持后台手动补录。家属/移动端预留方向现阶段不做复杂页面只把接口能力和角色权限预留出来后续接小程序时可以快速扩展。模块边界上的划分是后端只负责业务能力和数据安全前端只负责交互呈现。老人的档案字段怎么定义、告警规则怎么计算是后端的事表格怎么展示、图表怎么刷新是前端的事。两边通过一套统一的 RESTful 接口对接响应结构固定为 { code, message, data } 三要素前端所有的请求都按这个结构解包。这样划分的好处是项目中途出现过一次前端页面重构接口完全没动后端服务也没有受影响。前后端分离到这种程度联调时扯皮的事就少了一大半。2. MySQL表结构与MyBatis持久层的设计细节2.1 核心业务表怎么一步步拆出来的表结构是整个项目的骨架这个环节多花一天时间设计后面少花一周时间改代码。我按人-设备-事件-任务的思路把核心表拆了出来。表名职责关键字段sys_user平台用户管理员/护工/家属username, password, role_type, community_idcommunity社区/站点信息name, address, manager_idelder_info老人档案name, gender, birth_date, room_no, health_status, worker_idfamily_bind老人与家属绑定关系elder_id, family_user_id, relationdevice_info监护设备台账device_code, device_type, elder_id, bind_time, statusdevice_record设备上报记录核心流水表device_id, elder_id, heart_rate, blood_oxygen, steps, battery, is_sos, record_timealert_record告警记录elder_id, alert_type, alert_level, content, status, handler_id, handle_timework_order工单/任务elder_id, alert_id, task_type, assignee_id, status, finish_timerule_config告警规则配置rule_type, compare_type, threshold_value, is_enabled有两个地方在设计时被反复讨论值得拿出来说。第一是老人档案和住址信息的关系。一开始我简单地在 elder_info 表里放了 community_id 和 room_no 两个字段后来发现同一个老人可能在多个社区有服务记录比如白天在日间照料中心晚上回自己家就把服务关系抽成了独立表。但考虑到第一版并不想过度建模最终保留了 community_id 直配的方式等真的有一人多社区场景再拆分。这是我很想提醒的一点表结构不要一开始就设计得过度抽象满足当前清晰业务、留好扩展余地就够了。第二是 device_record 这张流水表的索引设计。设备上报数据是所有表里增长最快的一张表到后期动辄上百万行。我在 device_id 和 record_time 上建了联合索引这样按设备查时间范围非常快。同时我在建表时给 record_time 设置了 DEFAULT CURRENT_TIMESTAMP避免设备上报时因为少传字段导致写入失败。下面是建表的核心片段可以直接参考CREATE TABLE device_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, device_id BIGINT NOT NULL, elder_id BIGINT NOT NULL, heart_rate INT DEFAULT NULL, blood_oxygen INT DEFAULT NULL, steps INT DEFAULT NULL, battery INT DEFAULT NULL, is_sos TINYINT DEFAULT 0, record_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_device_time (device_id, record_time), KEY idx_elder_time (elder_id, record_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;唯一让人犹豫的是要不要做分表。后来评估了数据增长量一个设备每 5 分钟上报一次一天才 288 条一百个设备一天 2.88 万条半年下来 500 万条左右。这个量级在 MySQL 单表里其实还能撑只要索引合理、查询走索引问题不大。分表是优化手段而不是初始设计过早分表会显著增加代码复杂度我选择先不分表等真到了瓶颈再说。2.2 MyBatis动态SQL在灵活查询场景里的实际写法老人档案查询是典型的条件不确定场景管理员可能在页面上选年龄区间、选楼栋、选健康状态也可能只是输入一个姓名模糊搜索。这些条件可以自由组合如果用 Java 代码拼 SQL 字符串既危险又难维护MyBatis 的动态 SQL 就是为这种情况准备的。我写了一个比较经典的 mapper XML 片段你们可以感受一下这种写法的自然程度select idlistElderByCondition resultTypecom.demo.elder.entity.ElderInfo SELECT * FROM elder_info where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testcommunityId ! null AND community_id #{communityId} /if if teststartAge ! null and endAge ! null AND birth_date BETWEEN #{startDate} AND #{endDate} /if if testhealthStatus ! null and healthStatus ! AND health_status #{healthStatus} /if /where ORDER BY id DESC /selectwhere标签会自动处理第一个条件前面的 AND写条件永远不用担心多余逗号和连接符这是 MyBatis 解决动态条件拼装最实用的一招。另外一个我在批量场景里用得很多的是foreach。比如管理员在后台批量给老人绑定设备、批量修改某栋楼老人的护工负责人一条语句就能完成批量插入insert idbatchBindDevice INSERT INTO device_info (device_code, device_type, elder_id, status) VALUES foreach collectionlist itemitem separator, (#{item.deviceCode}, #{item.deviceType}, #{item.elderId}, 1) /foreach /insert比起在 Java 里循环单条插入批量 SQL 能减少数据库交互次数实测在绑定 100 台设备时耗时从 8 秒级别降到 1 秒以内。2.3 TypeHandler处理设备状态枚举的小插曲设备状态、告警级别这类字段MySQL 里存的是 int但 Java 里用枚举更符合业务语义。如果每处都手动做 int 和枚举的转换代码会非常啰嗦。MyBatis 的 TypeHandler 就是解决这个问题的。比如 DeviceStatus 枚举有 0离线 1在线 2维修中我需要让 MyBatis 在写入时自动把枚举转成 int在读取时自动把 int 转回枚举。自定义 TypeHandler 的核心逻辑如下MappedTypes(DeviceStatus.class) MappedJdbcTypes(JdbcType.INTEGER) public class DeviceStatusHandler extends BaseTypeHandlerDeviceStatus { Override public void setNonNullParameter(PreparedStatement ps, int i, DeviceStatus parameter, JdbcType jdbcType) throws SQLException { ps.setInt(i, parameter.getCode()); } Override public DeviceStatus getNullableResult(ResultSet rs, String columnName) throws SQLException { return DeviceStatus.fromCode(rs.getInt(columnName)); } }然后在 mybatis-config.xml 里注册一下实体类的字段映射时就自动生效了。第一版我只在 device_info 表的 status 字段上用到了这个后来发现告警级别、任务状态也都有类似需求就把公共枚举的 handler 统一维护起来避免重复造轮子。这里有个实操坑如果在配置文件中遗漏了MappedTypes或者没有注册 handlerMyBatis 通常会走默认的 EnumTypeHandler存枚举名称字符串那样你数据库里的 0/1 会变成字符串枚举名对不上号。排查的时候第一反应要去看 MyBatis 的控制台 SQL 日志里面会把绑定参数的类型和值打印出来能一眼看出是不是 handler 没生效。3. SpringBoot后端业务功能拆解从登录鉴权到告警触发3.1 基于JWT拦截器的多角色权限控制社区平台不像互联网产品那样允许游客随便逛值班员、护工、家属这三类角色看到的页面和数据范围完全不同。我用了 JWT HandlerInterceptor 的方案做认证和授权不需要服务端保存会话状态对前后端分离部署的场景很友好。登录成功后后端根据用户 ID 和角色生成一个 JWT token过期时间设计为 8 小时因为值班员是轮班制的8 小时覆盖一个班次到期后强制重新登录也合理。密钥不写在代码里放在 application.yml 中通过环境变量注入避免硬编码泄露。拦截器里做了两层校验先解析 token 是否合法再根据请求的 URL 前缀判断当前角色是否有权限访问。比如/api/work/order只能由管理员和护工访问家属角色访问会被直接拒绝。核心逻辑大致长这样Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { throw new BizException(未登录或登录已过期); } Claims claims JwtUtil.parseToken(token); if (claims null) { throw new BizException(token无效); } Integer roleType (Integer) claims.get(roleType); String uri request.getRequestURI(); // 简化示例工单接口只允许管理员和护工 if (uri.startsWith(/api/work) !Arrays.asList(1, 2).contains(roleType)) { throw new BizException(当前角色无权限操作); } return true; } }这里想单独提醒一件事统一异常处理非常重要。拦截器里抛出的异常如果不在全局RestControllerAdvice里捕获最终返回给前端的会是 Spring 默认的错误页而不是 JSON 结构前端 axios 解包就会报错。我的做法是自定义一个 BizException全局异常处理器统一捕获后返回 { code: 403, message: 无权限 }前端响应拦截器直接按这个结构处理。3.2 设备上报接口幂等设计与告警触发设备上报是平台数据流量的入口也是问题最多的环节。通信链路偶发抖动时同一个数据包可能会被设备重发也可能被网关转发两次。如果接口不做幂等处理device_record 表会出现重复记录告警也会被重复触发值班员一个晚上收到十条相同告警整个系统就失去了可信度。幂等方案我选了最简单可靠的唯一键INSERT IGNORE组合。具体做法是在 device_record 表上增加一个 unique_key 字段由设备 ID 上报时间计算 MD5 生成同时在该字段上建唯一索引。插入前先计算 unique_key然后执行 INSERT IGNORE如果影响行数为 0说明这条记录已经存在直接视为重复上报不再往下处理。String rawKey deviceId _ recordTime; String uniqueKey DigestUtils.md5DigestAsHex(rawKey.getBytes(StandardCharsets.UTF_8));这个方案的优点是不需要额外的 Redis 配合纯 MySQL 就能挡掉重复数据。缺点是重复请求还是会走一次数据库写入虽然 IGNORE 了但在这个量级下完全没问题。告警触发放在数据写入成功之后。规则从 rule_config 表里读取设备上报数据逐条和规则做比对核心判断逻辑是心率超过 160 或者低于 40、SOS 按钮被按下、离床传感器持续离床超过 10 分钟。其中 SOS 是最高优先级只要 is_sos1不管其他指标是否正常都直接产生告警。规则比对放在上报线程里同步做还是异步做我犹豫过。同步做的好处是告警实时坏处是上报接口响应时间会变长。后来我用了一个折中方案数据写入成功后把解析过的数据发到一个内存队列里由后台消费线程异步做规则比对和告警生成。这样上报接口本身保持轻量同时告警的延迟也只在百毫秒级别值班员完全感知不到。3.3 定时任务扫批离线检测、数据归档与统计落库设备在线状态不能只靠上报时更新还要有定时检测兜底。我给 device_info 表增加了 last_report_time 字段每次上报时更新。Spring 的 Scheduled 每 2 分钟扫一次全量设备凡是 last_report_time 超过 5 分钟未更新的设备统一标记为离线并写入一条设备离线告警。这是监护平台里最基础的兜底逻辑设备本身是否活着必须系统自己判断不能依赖人为观察。Scheduled(fixedRate 120000) public void checkDeviceOffline() { ListDeviceInfo deviceList deviceMapper.listAll(); LocalDateTime now LocalDateTime.now(); for (DeviceInfo device : deviceList) { if (device.getLastReportTime() ! null now.minusMinutes(5).isAfter(device.getLastReportTime())) { deviceMapper.updateStatus(device.getId(), DeviceStatus.OFFLINE); alertService.createAlert(device.getElderId(), AlertType.DEVICE_OFFLINE); } } }这个逻辑看起来简单部署时有个坑差点把我埋了Scheduled 在单实例下没问题但如果后面前端做了负载均衡同一个定时任务在多个实例上会同时执行导致重复发告警。我在设计之初就强制单实例部署后端并且把所有定时任务集中到单独的 ScheduleTaskService 中管理这样排查调度问题时有明确的边界。统计落库也是靠定时任务完成的。每天凌晨 2 点系统统计前一天各社区的告警数量、设备在线率、SOS 事件数等指标写入 stat_daily 表。大屏页面直接查这张统计表而不是实时对 device_record 做 group by查询速度能差出两个数量级。这类空间换时间的思路在报表场景里几乎每次都用得上。4. Vue3前端管理后台的工程化实践4.1 工程初始化与目录结构设计前端这块我最初想过用低代码平台拖一个后台出来后来发现监护场景的交互自由度很大大屏要实时刷新、告警列表要闪烁提示、工单要支持拖拽指派低代码平台在这种业务下反而束手束脚。最终决定老老实实用 Vite Vue3 TypeScript 搭工程加上 Element Plus、Pinia、Vue Router 三个核心依赖。一个值得一开始就定好的事情是目录结构。项目里按页面模块划分不按文件类型划分因为按组件/接口/工具这样分页面一多就会陷入到处跳转的困境。我的实际目录结构是src/ ├── api/ // 按业务模块封装的接口请求 │ ├── elder.ts │ ├── device.ts │ ├── alert.ts │ └── auth.ts ├── views/ // 页面级组件 │ ├── dashboard/ // 数据大屏 │ ├── elderManage/ // 老人档案 │ ├── deviceManage/ // 设备管理 │ ├── alertManage/ // 告警工单 │ └── system/ // 系统设置 ├── components/ // 公共组件 ├── stores/ // Pinia 状态管理 ├── router/ // 路由定义与守卫 ├── utils/ // axios/格式化等工具 └── types/ // 全局类型定义按照业务模块划分目录路由、API、页面组件之间的对应关系非常直观。新功能进来时开发人员闭着眼睛都知道代码该放哪这是项目长期维护中最省心的一环。初始化工程时有几个小坑提醒一下Vite 的 Node 版本要求比较高如果你是 Node 14 的老环境跑npm create vitelatest大概率会报错先把 Node 升到 18 以上。Element Plus 的图标组件是独立包需要单独安装element-plus/icons-vue很多人以为组件库自带图标结果页面空白半天才发现少了这一步。路由懒加载配合动态 import首屏加载时间能明显下降在管理后台这种页面多的场景下值得做。按路由拆包后登录页首屏 JS 体积能减少一半以上。4.2 Axios二次封装请求拦截、响应拆包与Token过期处理前端所有请求都走 axios但直接裸用 axios 在管理后台里写起来会很痛苦每个请求都要手动塞 token、手动判断错误码、手动跳登录页。我做了两层封装一次把这些问题全解决。第一层是实例创建配置 baseURL 和超时时间。第二层是拦截器请求拦截器从 Pinia 里拿 token 放进 header响应拦截器统一拆包遇到 code401 时清空用户信息并跳回登录页。const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL as string, timeout: 15000 }) service.interceptors.request.use((config) { const authStore useAuthStore() if (authStore.token) { config.headers.Authorization Bearer ${authStore.token} } return config }) service.interceptors.response.use( (response) { const res response.data if (res.code 200) { return res.data } if (res.code 401) { const authStore useAuthStore() authStore.logout() router.push(/login) return Promise.reject(new Error(登录已过期)) } ElMessage.error(res.message || 操作失败) return Promise.reject(new Error(res.message)) }, (error) { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )这个封装完成之后页面里请求代码就变得非常干净所有接口函数直接返回业务数据本身页面不用关心 HTTP 状态码也不用到处写 ElMessage.error 弹提示。后来接大屏图表数据时页面里每个图表组件都只调用一行getStatData()代码可读性提升非常明显。4.3 ECharts数据可视化大屏的实现思路值班室大屏是整个平台最有存在感的功能也是领导最喜欢看的页面。大屏主要展示本社区老人总数、设备在线率、实时告警滚动列表、今日心率异常曲线、告警等级占比饼图。图表库我选了 ECharts原因很简单社区项目用不起商业 DataVECharts 免费、社区案例多、API 成熟。Vue3 里使用 ECharts 不需要额外封装直接引入 echarts 核心包在组件 mounted 时初始化实例即可。大屏数据的实时性用 setInterval 轮询实现。刚开始我每 2 秒轮询一次全部数据接口压力有点大。后面做了区分实时告警列表和在线率是高频数据每 5 秒拉一次今日心率曲线和占比饼图是低频数据每 30 秒拉一次。页面切换到大屏之外时在 onUnmounted 里清掉所有定时器避免组件卸载后定时器还在发请求那是很常见的性能泄漏点。有个真实调优经验值得记录ECharts 的 setOption 在频繁更新数据时如果每次都全量替换配置项会有明显的闪烁和卡顿。正确做法是用 setOption 的第二个参数 notMergefalse默认就是 false并且只更新变化的 series 数据。我更推荐的做法是图表初始化时把完整的 option 配置好后续更新只调用chart.setOption({ series: [{ data: newData }] })只更新数据数组这样动画过渡非常平滑。5. 联调与部署阶段踩过的坑附完整排查过程5.1 跨域问题配置了CORS还是报错问题到底在哪前后端分离项目跨域问题几乎是必定遇到的。我在开发阶段用 Vite 的 proxy 做代理转发前端环境访问/api时自动转发到后端一切正常。结果一部署到生产环境的 Nginx 上页面全部请求失败浏览器控制台报了一堆 CORS error。排查过程是这样的第一步打开浏览器 F12 看具体请求。发现所有 POST 请求直接进入失败状态而且不是业务返回的非 200 状态而是Access-Control-Allow-Origin相关错误。第二步检查后端的跨域配置。我在 SpringBoot 里已经写了一个 WebMvcConfigurer 的 CorsFilterallowedOrigins 配置的是*。按理说这种情况下不应该报跨域。第三步精确定位错误信息里实际提示的是 Access-Control-Allow-Headers 缺少 Authorization。因为*通配符在 Spring 的实现里并没有覆盖所有自定义 header前端带了 Authorization 头预检请求就过不了。解决办法是显式指定允许的请求头而不是依赖通配符registry.addMapping(/api/**) .allowedOrigins(https://admin.example.com) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(Authorization, Content-Type) .allowCredentials(true) .maxAge(3600);跨域的坑往往不在跨域本身而在看起来已经配置了但配置不完整。我发现很多同行遇到跨域第一反应是加 allowedOrigins( *)但实际业务中带了 token 头、带了 cookies 之后*根本扛不住。正确姿势是按实际的 header、method、origin 需求精确配置越具体越稳。5.2 MySQL时区参数引发的日期错乱问题设备上报的时间字段在数据库存的是 datetime 类型Java 侧实体用的是 LocalDateTime。数据写入后理论上时间应该和前端传的一致。但部署到一台新的云服务器之后运维反馈设备数据时间全部慢了 8 小时。排查链路一步步收紧先看后端日志打印出来的服务时间是通过LocalDateTime.now()获取的时间正常。再看数据库里存的 record_time 字段值发现存进库的时间确实少了 8 小时。这时基本确定是 JDBC 连接串的问题而不是业务代码的问题。最后看 MySQL 驱动与连接参数发现 JDBC URL 是下面这个样子jdbc:mysql://localhost:3306/elder?useSSLfalseallowPublicKeyRetrievaltrue没有指定 serverTimezone 参数。MySQL Connector/J 在未指定时区时会取服务器系统时区。如果应用服务器的时区和数据库服务器的时区不一致或者驱动版本敏感就会出现这种偏移 8 小时的问题。解决方式很简单JDBC URL 末尾加上时区参数jdbc:mysql://localhost:3306/elder?useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai这个坑的隐蔽性在于本地开发时应用和数据库在同一台机器上时区一致所以完全不报错换到云服务器部署两个环境的时区配置不同问题立刻暴露。建议从一开始就在 JDBC 连接串里显式写好 serverTimezone不要依赖环境默认值。5.3 高频写入导致的慢SQL一步步定位和优化上线运行几周后有一次我在后台查看监控发现 device_record 表的写入出现了明显的变慢。业务高峰期每条上报数据的插入耗时从原来的 3 毫秒飙到 200 毫秒再往上叠加时接口响应就变得不可接受。定位慢 SQL 的过程很常规但很有效。先在 MySQL 开启慢查询日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0.5;跑了一个小时后去看慢查询日志发现排在时间消耗前列的全都是对 device_record 表的单条 INSERT 语句和报表的大范围 count 查询。分析原因有两个第一上报频率高单条插入在高峰期大量堆积加上实时大屏频繁查询当天数据行锁和索引维护的开销叠加在一起。 第二device_record 表的数据量增长很快全部数据都在同一张表里索引的 B 树层级变深写入变慢是意料之中的。优化措施分了三个层次按见效速度排序批量写入上报接口不再逐条 insert而是把数据先放到内存队列每 5 秒批量执行一次 INSERT用foreach一次性写入一批。实测高峰期的写入总耗时下降非常明显接口响应时间从 200 毫秒降回 10 毫秒以内。调整索引删掉了几个单独建的索引改成更精准的联合索引。对这个表来说查询基本都围绕某个设备一段时间内的记录和某个老人在某天的记录所以 idx_device_time 和 idx_elder_time 这两个联合索引就覆盖了绝大部分场景。数据归档device_record 表按月分区或者超过三个月的旧数据迁移到备份表。这一步在当时没有立刻做但我把分区方案写进了运维手册等表数据量再涨一倍就直接启用。这次优化让我彻底理解了批量操作在真实业务里的分量。很多开发在 CRUD 阶段习惯了逐条操作觉得批量插入是多余的复杂度但当业务量级上来之后批量操作是性价比最高的第一优化手段。6. 项目交付后的体会与几条扩展思路这个平台从设计到交付前后花了大概两个半月。技术上其实没有太高深的东西框架都是现成的真正难的是如何把这么多功能拧成一个完整的业务闭环。项目上线之后值班员从打开四个系统抄数据变成盯一个屏幕就行仅从操作效率上说成果已经很明显了。但我在复盘时发现真正的经验不是代码层面的而是对业务逻辑的理解。举一个例子告警规则的阈值。最开始我在规则表里配的是统一的心率大于 160 才算异常结果上线第二天就发现一位患心脏病的老人静息心率本身就 140 多几乎天天空报。护工后来直接在系统里把这位老人的阈值调到 180误报立刻就少了。这个改动本身不复杂但它提醒了我监护系统的规则引擎从一开始就应该是按人可配的而不是全局一套规则。业务上合理的默认值落到不同的具体人身上就可能不合理。再比如数据的可靠程度。设备上报偶尔丢一条数据从技术角度是可以接受的但从监护角度看可能就会漏掉一次异常。所以我在系统里加了校验逻辑如果发现某台设备超过 5 分钟没上报就自动告警这比事后查数据丢没丢要有效得多。监护系统的第一原则是异常能浮上来数据完整反而是第二位的。最后想聊聊这个项目后续可以怎么扩展。目前只是接入了手环、床垫、SOS 按钮这几类简单的设备数据其实还有不少很自然的方向接视频监控联动老人房间触发跌倒告警时值班台自动弹窗显示对应摄像头画面。技术上只需要在告警事件里带上一个摄像头编号前端根据编号拉起视频流地址就行。对接智能药盒/血压计这类设备的数据格式差异很大但核心逻辑都是设备-记录-告警可以复用现有的上报链路做扩展。做家属端小程序目前家属只能通过后台查看后续做个小程序让家属能收到告警推送。接口权限已经预留了 FAMILY 角色这一层就差 UI 和通知通道了。用 MinIO 统一存附件老人档案的头像、健康报告 PDF、工单的照片附件目前是散落在服务器本地目录里的。按这个平台的数据增长量迟早要接一个统一文件存储服务MinIO 就很合适。根据我自己的实操体会社区类项目的核心逻辑从来不是功能越多越好而是每一条数据能不能在一个可解释的流程里流转。文档再厚、图表再炫底层数据不可信这个平台就失去了价值。这是我做完这个项目之后最深的感受也是后续每一次扩展时最先考虑的问题。
返回列表