-- ============================================================================ -- POST /smartor/patinhosp/selectPatMedInhospList 慢查询诊断 + 索引脚本 -- -- 现场入参(在院视图): -- {"bedSort":1,"pageNum":1,"pageSize":20, -- "deptcodeList":["40003378"],"hospitaldistrictcodeList":["40003380"],"cry":0} -- -- 调用链(已逐行核对): -- InhospVisitDataController#selectPatMedInhosplis -- -> InhospVisitFacade#selectPatMedInhospList -- -> DefaultInhospVisitDataService#selectPatMedInhospList -- -> PatMedInhospMapper.xml (cry=0 走 endtime is null 分支; -- bedSort 传字符串 "1" 不匹配 XML 里的 "asc"/"desc",故落到 默认排序) -- -- SELECT a.age, a.telcode, a.sex, b.inhospid, b.nurse_id, ...(共 60+ 列)..., a.idcardno -- FROM pat_med_inhosp b -- JOIN pat_archive a ON a.id = b.patid -- WHERE 1=1 -- AND a.del_flag = 0 -- ← 数字常量比 char 列,见第 4 节 F1 -- AND b.del_flag = 0 -- ← 同上,且它正是 idx_inhosp_optimize 的首列 -- AND a.idcardno IS NOT NULL -- AND b.endtime IS NULL -- ← 在院判定(等价条件,无法用现有索引) -- AND b.hospitaldistrictcode IN ('40003380') -- AND b.deptcode IN ('40003378') -- AND b.orgid = '<登录用户 orgid>' -- 由 @AddOrgId 注入 -- [AND b.campusid = '<登录用户 campusid>'] -- 院区权限未放开时由 @AddOrgId 注入 -- GROUP BY b.inhospid, a.age, a.age_unit, a.telcode, a.id, a.sex -- ← 见第 4 节 F3 -- ORDER BY b.starttime DESC, b.bed_no ASC -- LIMIT 0, 20 -- -- ② 总数查询:PageHelper 自动生成。因为有 GROUP BY,isSimpleCount() 为 false, -- CountSqlParser 会【把整条查询包一层】而不是改写成 count(0)(已核对 pagehelper 5.3.x -- 字节码常量 "select count(" + ") from ( " + ") tmp_count"): -- -- SELECT count(0) FROM ( <① 去掉 ORDER BY 后的完整 60 列语句> ) tmp_count -- -- => 每次翻页 = 同一条重量级查询跑两遍。这是"慢"的第二个根因,见第 4 节 F2。 -- -- ③ 另外一条:fillTagname 不在这条链上(那是 smartor 侧 ServiceImpl 的逻辑),可排除。 -- ---------------------------------------------------------------------------- -- 第 1 步:先看现状 —— 现有索引、慢日志、以及这条 SQL 的真实执行计划 -- ---------------------------------------------------------------------------- -- 1.1 pat_med_inhosp / pat_archive 现有索引 -- 重点看 idx_inhosp_optimize 的真实列顺序(代码注释里写的是 (del_flag, orgid, inhospstate)) SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, CARDINALITY, NON_UNIQUE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('pat_med_inhosp', 'pat_archive') ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX; -- 1.2 关掉的条件到底是不是索引列、列类型对不对(del_flag / inhospstate 是 char 还是 int, -- 直接决定下面 F1 / F4 要不要改) SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('pat_med_inhosp', 'pat_archive') AND COLUMN_NAME IN ('inhospid', 'patid', 'del_flag', 'orgid', 'campusid', 'inhospstate', 'endtime', 'starttime', 'bed_no', 'deptcode', 'hospitaldistrictcode'); -- 1.3 表体量 + 在院占比(判断"全表扫描"到底贵不贵) SELECT TABLE_NAME, TABLE_ROWS, ROUND(DATA_LENGTH / 1024 / 1024, 1) AS data_mb, ROUND(INDEX_LENGTH / 1024 / 1024, 1) AS index_mb FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('pat_med_inhosp', 'pat_archive'); SELECT COUNT(*) AS total, SUM(endtime IS NULL) AS in_hosp_by_endtime, SUM(inhospstate = '0') AS in_hosp_by_state, -- 两种"在院"定义不一致的行数:>0 就说明【不能】随手把 inhospstate='0' 当作 -- endtime IS NULL 的替代条件,F2 只能走"加索引"那条路 SUM(CASE WHEN endtime IS NULL AND IFNULL(inhospstate, '') <> '0' THEN 1 ELSE 0 END) AS in_hosp_def_conflict FROM pat_med_inhosp WHERE del_flag = '0'; -- 1.4 直接 EXPLAIN 这条语句(把 orgid 换成现场值) EXPLAIN SELECT a.age, a.telcode, a.sex, b.inhospid, b.patid, b.deptcode, b.bed_no, b.starttime, b.endtime FROM pat_med_inhosp b JOIN pat_archive a ON a.id = b.patid WHERE a.del_flag = 0 AND b.del_flag = 0 AND a.idcardno IS NOT NULL AND b.endtime IS NULL AND b.hospitaldistrictcode IN ('40003380') AND b.deptcode IN ('40003378') AND b.orgid = '<换成现场 orgid>' GROUP BY b.inhospid, a.age, a.age_unit, a.telcode, a.id, a.sex ORDER BY b.starttime DESC, b.bed_no ASC LIMIT 0, 20; -- -- 预期(改之前):b 表 type=ref/range 或 ALL,key 只有 idx_inhosp_optimize 的 -- (del_flag, orgid) 前缀能用到(或完全用不上); -- rows 约等于"该机构全部历史住院记录数",Extra 里出现 Using where; Using temporary; Using filesort。 -- 改之后要点:b 表 rows 应降到"该机构在院人数"量级(数千),且 key 命中下面的新索引 / 现有索引全前缀。 -- ---------------------------------------------------------------------------- -- 第 2 节:两种修法 —— 【本次采用修法 A】,修法 B 留作备选 -- ---------------------------------------------------------------------------- -- 修法 A(本次采用,零语义风险):给"在院"补一个能定位的索引。 -- 现有 idx_inhosp_optimize 的第三个等值列是 inhospstate,但本接口不传这个条件, -- 只能用上 (del_flag, orgid) 前缀 = 该机构全部历史住院记录,等于没走索引。 -- 前提:F1 的 del_flag = '0' 已改(否则首列用不上,这条索引也白建)。 ALTER TABLE pat_med_inhosp ADD INDEX idx_pmi_del_org_end (del_flag, orgid, endtime); -- 可选加强:把两个 IN 条件也放进索引,命中行不用回表就能判掉(索引更宽,写放大更明显) -- ALTER TABLE pat_med_inhosp -- ADD INDEX idx_pmi_del_org_end_dept (del_flag, orgid, endtime, deptcode, hospitaldistrictcode); -- 出院 / 转科 / 预入院 三个视图(cry=1/2/3,order by b.endtime desc)用这条: -- 等值列 (del_flag, orgid, inhospstate) 全命中,且 endtime 可以顺带避免 filesort 的排序键 ALTER TABLE pat_med_inhosp ADD INDEX idx_pmi_del_org_state_end (del_flag, orgid, inhospstate, endtime); -- 修法 B(本次不采用,留作备选):不加索引,cry=0 时补 inhospstate='0',让现有 -- idx_inhosp_optimize 的 (del_flag, orgid, inhospstate) 前缀全部等值命中 —— -- 其实这才是【迁移前的行为】:smartor 的 PatMedInhospServiceImpl#selectPatMedInhospList -- 在 cry=0 时会 setInhospstate("0"),迁到 ruoyi-inhospvisit 后这条链直接调 Mapper, -- 这段归一化丢了。 -- ⚠️ 之所以没选它:它会让"endtime 为空但 inhospstate<>'0'"的行静默消失, -- 是否安全取决于数据,得先看第 1.3 节的 in_hosp_def_conflict。 -- 若将来确认 = 0,这一条比建索引更省(见 PatMedInhospMapper.xml 的 -- 分支)。 -- ---------------------------------------------------------------------------- -- 第 3 节:验证(第 2 步做完后重跑第 1.4 节) -- ---------------------------------------------------------------------------- -- ① b 表 type = ref,key = idx_pmi_del_org_end(或 idx_inhosp_optimize),rows 数千级; -- ② Using temporary; Using filesort 仍会有(GROUP BY + 床位排序导致,见 F3), -- 但因为参与排序的行数已经从"全机构历史"降到"本部门在院",可以接受; -- ③ 接口耗时:count + data 两条都按这个计划走,整体应降到几十毫秒级。 -- ---------------------------------------------------------------------------- -- 【回滚】 -- ---------------------------------------------------------------------------- -- ALTER TABLE pat_med_inhosp DROP INDEX idx_pmi_del_org_end; -- ALTER TABLE pat_med_inhosp DROP INDEX idx_pmi_del_org_state_end; -- ============================================================================ -- 第 4 节:代码侧顺带发现的问题(按影响排序) -- ✅ 已改:F1 / F2 / F4 ⏸ 未改(等确认):F3 / F5 / F6 -- -- F1 ✅ 已改 b.del_flag = 0 / a.del_flag = 0:数字常量比 char 列,MySQL 会把【列】转 -- DOUBLE,该条件只能过滤、不能定位。idx_inhosp_optimize 的首列就是 del_flag, -- 等于首列白给。已改成 = '0'。本仓库已有同类结论,见 -- sql/sltdMydTotalByDimension_index_20260924.sql 第 117-134 行:"代码侧必须同步修正 -- del_flag 的写法,否则索引只能用上一半"。 -- (列是 int 时写 '0' 也安全:常量侧转换,索引照样可用。) -- -- F2 ✅ 已改 PageHelper 的 count:有 GROUP BY 时不能简化,只能包一层 tmp_count, -- 等于每次翻页把这条 60 列查询再跑一遍。已在 -- ruoyi-inhospvisit/.../PatMedInhospMapper.xml 补 selectPatMedInhospList_COUNT -- (pagehelper 5.3.x 的 PageInterceptor 支持 countSuffix="_COUNT" 的手写 count, -- 已核对字节码常量:genCountMsId / getExistedMappedStatement / executeManualCount), -- 并把 FROM/WHERE 抽成 selectPatMedInhospListFromWhere 片段给两条语句共用, -- 保证 count 与列表永远是同一套条件。 -- -- F3 ⏸ 未改 GROUP BY b.inhospid, a.age, a.age_unit, a.telcode, a.id, a.sex 恒等、不去重: -- a.id 是 pat_archive 主键、b.patid 只关联到一行,每个 join 结果行自成分组。 -- 它只换来 Using temporary + Using filesort。删它之前要先确认 pat_archive.id 唯一; -- 确认后删掉,F2 的 count 还会自动变成简单的 count(0)。 -- -- F4 ✅ 已改 cry=1/2/3 分支原来是 b.inhospstate=1 / =2 / =3(数字常量 vs char 列),同 F1, -- 出院 / 转科 / 预入院 三个视图的索引也用不上,已改成 '1' / '2' / '3'。 -- -- F5 ⏸ 未改 bedSort 在这条链上没有归一化。前端传 bedSort:1(数字),Jackson 落到 String -- 是 "1",而 XML 只认 "asc"/"desc",所以【按床位排序实际没生效】,落回默认 -- order by starttime desc, bed_no asc。归一化函数 normalizeBedSort 目前只在 -- smartor 的 ServiceImpl 里,而这条链已经不走它了。 -- -- F6 ⏸ 未改 date_format(b.endtime,'%y%m%d') >= date_format(?, '%y%m%d') 这类写法 -- (出院/入院时间范围过滤,本入参不涉及)对列用了函数,索引必失效。等价改法: -- >= date_format(?, '%y%m%d') -> b.endtime >= DATE(?) -- <= date_format(?, '%y%m%d') -> b.endtime < DATE(?) + INTERVAL 1 DAY -- -- 回归检查:python sql\_probe\verify_patinhosp_list_count.py(17 项,改完请跑一次) -- ============================================================================