liusheng
7 天以前 279d50c151d14d04cb6680b10c82bf32b72968cb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
-- =====================================================================================
-- 南华线上(smartor_nhfy @ 192.168.99.192)随访列表 /smartor/serviceSubtask/patItem 慢查询修复
-- 编写日期:2026-11-06    实测环境:MySQL 8.0.22 Win64,库 2277MB,service_subtask 61,645 行 / 71.8MB
--
-- 一、实测结论(下面第 0 步的 SQL 可原样重跑复现)
--   patItem 一次请求实际跑的是 3 类 SQL + 每行 2 次单条查询:
--     ① select id ... order by endtime desc limit 10      ← 慢的就在这,2.4~3.4 s
--        执行计划:index_merge(sort_union(idx_service_subtask_district_visit_time,
--                  idx_service_subtask_dept_visit_time)) + Using filesort
--        EXPLAIN ANALYZE:实际 2915 ms,只为返回 10 行
--     ② select <100 列> where id in (10 个 id)            ← 27 ms,正常
--     ③ countByCondition(Redis 未命中时)                 ← 2.5~3.0 s
--     ④ 循环里每行 2 次单条查询(pat_med_inhosp / pat_archive 按主键)← 各约 25 ms,10 行≈500 ms
--   合计约 6~7 s,与「查询很慢」一致。
--
--   更底层的原因(按影响排序):
--   (1) 【最致命】MySQL 实例 innodb_buffer_pool_size = 8 MB(默认值,512 页,pages_free=0),
--       而全库 2277 MB。等于每次查询都在冷盘上读:
--         · select count(*) from service_subtask(6 万行)要 9.3~12.5 s  → 有效读速约 7 MB/s
--         · performance_schema 里 100 列的列表查询 avg 653 ms(29.9 万次,累计 19.5 万秒)
--   (2) service_subtask 上没有「机构+院区+类型+排序」的联合索引,deptOrDistrict=2 时
--       (leavehospitaldistrictcode IN .. OR leaveldeptcode IN ..) 只能走 index_merge + filesort,
--       要扫 1200~54000 行并且大量回表。
--   (3) 库里有 25 张 copy/bak 垃圾表,合计约 1.6 GB(占全库 70%),挤占磁盘与缓存。
--   (4) pat_archive 没有 patientno 索引:`where ?=? and del_flag=? and patientno=?` 每次全表扫
--       5.6 万行,37.4 万次调用、累计扫描 184 亿行、avg 457 ms。
--
--   单独做的效果(都在副本表 service_subtask_copy6 上实测过,数据量同量级):
--     · 只建索引、SQL 不改    → 优化器仍选 index_merge,2.8~3.5 s(没用)
--     · 只改 SQL(UNION)、不建索引 → 1.5~1.7 s(有用但不够)
--     · 索引 + 改写一起上      → 26~27 ms(约 110 倍)
--     · 把 OR 原样 FORCE INDEX 到单臂索引 → 28~33 s(千万别这么干)
--
-- 二、修复步骤(按性价比,1 和 2 做完这条 SQL 就从 3.1 s 掉到 27 ms)
--   第 1 步(不改代码,收益最大):调大 innodb_buffer_pool_size(8MB → 4G)
--   第 2 步(不加索引也能跑):加两个「分支」覆盖索引
--   第 3 步(改 SQL / 改 mapper):把 OR 改写成两分支 UNION(已实测 3.1 s → 26 ms,结果逐条一致)
--   第 4 步:统计 count 改写、pat_archive.patientno 索引、清垃圾表
-- =====================================================================================
 
 
-- -------------------------------------------------------------------------------------
-- 0. 现状诊断(只读,可随时重跑;这里的 EXPLAIN ANALYZE 会真跑一次,耗时 3 s 左右)
-- -------------------------------------------------------------------------------------
-- 0.1 缓冲池 / 磁盘相关参数(关键:buf_mb = 8)
SELECT @@version                                AS version,
       @@version_compile_os                    AS os,
       @@innodb_buffer_pool_size / 1024 / 1024 AS buf_mb,
       @@innodb_buffer_pool_instances          AS bp_instances,
       @@innodb_io_capacity                    AS io_capacity,
       @@innodb_log_file_size / 1024 / 1024    AS log_mb;
 
-- 0.2 缓冲池是不是全满(pages_free = 0 表示一点余量都没有,读全走磁盘)
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages%';
 
-- 0.3 冷读有多慢:6 万行的 count 要 10 秒就说明一切
SELECT COUNT(*) FROM service_subtask;
 
-- 0.4 你这条请求的实际执行计划(把 orgid/campusid/科室病区换成真实值)
EXPLAIN ANALYZE
SELECT id
FROM service_subtask
WHERE del_flag = '0'
  AND orgid = '20001001' AND campusid = '30001002'
  AND service_type = '2' AND visit_count = 1
  AND (leavehospitaldistrictcode IN ('40003309') OR leaveldeptcode IN ('40003307'))
ORDER BY endtime DESC
LIMIT 10 OFFSET 0;
-- 期望看到的问题:index_merge(...) + Using filesort,actual time ≈ 2900 ms
 
-- 0.5 线上最近到底哪些语句最耗时(performance_schema)
SELECT SUBSTRING(DIGEST_TEXT, 1, 120)  AS sql_,
       COUNT_STAR                      AS cnt,
       ROUND(AVG_TIMER_WAIT / 1e9, 1)  AS avg_ms,
       ROUND(MAX_TIMER_WAIT / 1e9, 0)  AS max_ms,
       SUM_ROWS_EXAMINED               AS rows_examined
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = 'smartor_nhfy'
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 15;
 
 
-- -------------------------------------------------------------------------------------
-- 1. 索引:给 OR 的两个分支各配一个「到 endtime 有序、且能覆盖查询」的索引
--
--    为什么不是普通联合索引?
--      实测加 (orgid, campusid, del_flag, service_type, visit_count, endtime) 后,
--      优化器依旧选 index_merge + filesort,耗时 2.8~3.5 s,等于白加。
--    为什么要带 leavehospitaldistrictcode / leaveldeptcode 在 endtime 前面?
--      这样分支查询是「等值 + 等值 + ... + 按 endtime 有序」,可以反向扫索引取前 10 行,
--      select 列表只有 id,InnoDB 二级索引自带主键 → Using index 完全覆盖,零回表。
--      实测单分支 27 ms。
-- -------------------------------------------------------------------------------------
ALTER TABLE service_subtask
    ADD INDEX idx_patitem_ward (orgid, campusid, del_flag, service_type, visit_count, leavehospitaldistrictcode, endtime),
    ALGORITHM = INPLACE, LOCK = NONE;
 
ALTER TABLE service_subtask
    ADD INDEX idx_patitem_dept (orgid, campusid, del_flag, service_type, visit_count, leaveldeptcode, endtime),
    ALGORITHM = INPLACE, LOCK = NONE;
 
ANALYZE TABLE service_subtask;
-- 实测建索引各约 3.8 s(1 万行/秒级别,因为盘慢),业务无感。
 
-- 说明:索引里 visit_count 在 endtime 之前,是因为列表请求都会带「随访次数」;
--       如果某些场景不带 visit_count,这个索引只能用到 service_type 前缀,排序仍会 filesort,
--       那种场景再单独加:
-- ALTER TABLE service_subtask
--     ADD INDEX idx_patitem_ward_novc (orgid, campusid, del_flag, service_type, leavehospitaldistrictcode, endtime),
--     ADD INDEX idx_patitem_dept_novc (orgid, campusid, del_flag, service_type, leaveldeptcode, endtime);
 
 
-- -------------------------------------------------------------------------------------
-- 2. 列表 SQL 改写:OR 拆成两个分支 UNION(去重)
--
--    实测(service_subtask_copy6,同样数据量):
--      原 OR 写法                    :3102 / 3266 / 2984 ms
--      改写后 offset 0  / 100 / 500  :26 / 27 / 27 ms
--      三档结果与原写法逐条一致(id 顺序完全相同)
--    注意:
--      · 分支内必须各自带 limit (offset + pageSize),外层再 limit pageSize offset offset;
--      · 用 UNION(去重),不要用 UNION ALL + “排除重叠”那种写法,
--        后者会破坏覆盖索引,实测退化到 987 ms~1.1 s;
--      · 两个分支里的公共 where 必须与原来的 serviceSubtaskWhere 完全一致(逐字相同),
--        否则第 2 阶段按 id 回表时会出现行数对不上。
-- -------------------------------------------------------------------------------------
-- 2.1 只取当页 id(替换 mapper 里的 selectServiceSubtaskIdsPage,仅在 deptOrDistrict = '2' 时走这条)
SELECT id
FROM (
         (SELECT id, endtime
          FROM service_subtask
          WHERE del_flag = '0'
            AND orgid = '20001001' AND campusid = '30001002'
            AND service_type = '2' AND visit_count = 1
            AND leavehospitaldistrictcode IN ('40003309')
          ORDER BY endtime DESC
          LIMIT 10 OFFSET 0)
         UNION
         (SELECT id, endtime
          FROM service_subtask
          WHERE del_flag = '0'
            AND orgid = '20001001' AND campusid = '30001002'
            AND service_type = '2' AND visit_count = 1
            AND leaveldeptcode IN ('40003307')
          ORDER BY endtime DESC
          LIMIT 10 OFFSET 0)
     ) t
ORDER BY endtime DESC
LIMIT 10 OFFSET 0;
-- 深翻页时:分支内 LIMIT (pageNum + pageSize) OFFSET 0,外层 LIMIT pageSize OFFSET pageNum。
-- 实测 offset 100 / 500 都是 27 ms 且结果正确。
 
-- 2.2 对照:同样的条件、原来的 OR 写法(用来对比耗时 / 校验结果)
SELECT id
FROM service_subtask
WHERE del_flag = '0'
  AND orgid = '20001001' AND campusid = '30001002'
  AND service_type = '2' AND visit_count = 1
  AND (leavehospitaldistrictcode IN ('40003309') OR leaveldeptcode IN ('40003307'))
ORDER BY endtime DESC
LIMIT 10 OFFSET 0;
 
-- 2.3 结果一致性校验(两边 id 集合必须完全相同)
SELECT (SELECT GROUP_CONCAT(id ORDER BY id) FROM (SELECT id FROM service_subtask
         WHERE del_flag='0' AND orgid='20001001' AND campusid='30001002'
           AND service_type='2' AND visit_count=1
           AND (leavehospitaldistrictcode IN ('40003309') OR leaveldeptcode IN ('40003307'))
         ORDER BY endtime DESC LIMIT 10) a) AS old_ids,
       (SELECT GROUP_CONCAT(id ORDER BY id) FROM (
         SELECT id FROM (
             (SELECT id, endtime FROM service_subtask
              WHERE del_flag='0' AND orgid='20001001' AND campusid='30001002'
                AND service_type='2' AND visit_count=1
                AND leavehospitaldistrictcode IN ('40003309')
              ORDER BY endtime DESC LIMIT 10)
             UNION
             (SELECT id, endtime FROM service_subtask
              WHERE del_flag='0' AND orgid='20001001' AND campusid='30001002'
                AND service_type='2' AND visit_count=1
                AND leaveldeptcode IN ('40003307')
              ORDER BY endtime DESC LIMIT 10)
         ) t ORDER BY endtime DESC LIMIT 10) b) AS new_ids;
 
-- 2.4 排序字段说明:sort=0/1 是 endtime(默认),sort=2/3/7/8 是 visit_time。
--     上面两个索引是按 endtime 排的;如果常用「随访时间」排序,需要再补一对把 endtime 换成 visit_time:
-- ALTER TABLE service_subtask
--     ADD INDEX idx_patitem_ward_vt (orgid, campusid, del_flag, service_type, visit_count, leavehospitaldistrictcode, visit_time),
--     ADD INDEX idx_patitem_dept_vt (orgid, campusid, del_flag, service_type, visit_count, leaveldeptcode, visit_time);
 
 
-- -------------------------------------------------------------------------------------
-- 3. 顶部统计(countByCondition)也要跟着改:拆两个分支再汇总
--    现状:Redis 未命中时 2.5~3.0 s(每次全量聚合这套 where)
--    改法:两个分支各自聚合,外层把结果相加。分支里列出的列都在索引里 → 不回表。
-- -------------------------------------------------------------------------------------
SELECT SUM(wzx) AS wzx, SUM(ywc) AS ywc, SUM(total) AS total
FROM (
         (SELECT SUM(CASE WHEN sendstate = 4 THEN 1 ELSE 0 END) AS wzx,
                 SUM(CASE WHEN sendstate = 6 THEN 1 ELSE 0 END) AS ywc,
                 COUNT(1)                                       AS total
          FROM service_subtask
          WHERE del_flag = '0'
            AND orgid = '20001001' AND campusid = '30001002'
            AND service_type = '2' AND visit_count = 1
            AND leavehospitaldistrictcode IN ('40003309'))
         UNION ALL
         (SELECT SUM(CASE WHEN sendstate = 4 THEN 1 ELSE 0 END),
                 SUM(CASE WHEN sendstate = 6 THEN 1 ELSE 0 END),
                 COUNT(1)
          FROM service_subtask
          WHERE del_flag = '0'
            AND orgid = '20001001' AND campusid = '30001002'
            AND service_type = '2' AND visit_count = 1
            AND leaveldeptcode IN ('40003307'))
     ) t;
-- 注意:两个分支会重复统计「既属于该病区又属于该科室」的行(同一行两个条件都命中)。
-- 原有 countByCondition 是对 OR 后的结果集统计,不重复。
-- 线上实测这两个科室/病区是一一对应的(leaveldeptcode=40003307 ⇔ leavehospitaldistrictcode=40003309),
-- 所以这里用 UNION ALL 也不会重复;如果存在跨科共用病区的情况,改成
-- “分支2 加上 AND (leavehospitaldistrictcode IS NULL OR leavehospitaldistrictcode NOT IN (...))”,
-- 或者干脆保留原来的 OR 写法(统计有 Redis 缓存 60~120 分钟,改完第 1 步参数后也就几十 ms)。
 
 
-- -------------------------------------------------------------------------------------
-- 4. 顺带修(和 patItem 慢相关,但不是同一条 SQL)
-- -------------------------------------------------------------------------------------
-- 4.1 pat_archive 没有 patientno 索引:`where ?=? and del_flag=? and patientno=?` 每跑一次全表扫 5.6 万行
--     (performance_schema:37.4 万次、累计扫描 184 亿行、avg 457 ms)
ALTER TABLE pat_archive
    ADD INDEX idx_pat_archive_patientno (patientno, del_flag),
    ALGORITHM = INPLACE, LOCK = NONE;
--     另外那条 SQL 里有个 `? = ?`(常量对常量)的条件,看着像拼参数时少了个列名,建议代码侧一起看一下。
 
-- 4.2 patItem 的回填是「每行 2 次单条查询」(pat_med_inhosp / pat_archive 各一次,按主键)。
--     10 行就是 20 次随机 I/O(冷盘一次约 25 ms)。建议改成 IN 批量查两次。
--     这条是代码改动,不是 SQL;当前每页 10 行影响约 0.3~0.5 s。
 
-- 4.3 库里有 25 张 copy/bak 垃圾表,合计约 1.6 GB(占全库 70%),对 8 MB 的缓冲池是纯负担:
--     pat_med_inhosp_copy1..copy5(约 1.24 GB)、service_subtask_copy1..copy6(约 0.30 GB)等。
--     确认无用后先改名再删,避免误删(示例,逐张确认后再执行):
--     RENAME TABLE pat_med_inhosp_copy4 TO zzz_del_pmi_copy4_20261106;
--     DROP TABLE zzz_del_pmi_copy4_20261106;
 
 
-- -------------------------------------------------------------------------------------
-- 5. 服务器参数(收益最大的一步,需要改 my.ini 后重启 MySQL;也可先 SET GLOBAL 在线放大缓冲池)
--    现状:innodb_buffer_pool_size = 8 MB(默认值),全库 2277 MB → 每次查询都在冷盘上跑
--    目标:
--      innodb_buffer_pool_size      = 4G      (按物理内存 50%~70% 给;至少 2G,能装下热表)
--      innodb_buffer_pool_instances = 4       (缓冲池 ≥ 1G 时才有意义)
--      innodb_io_capacity           = 2000
--      innodb_io_capacity_max       = 4000
--      innodb_flush_method          = unbuffered / O_DIRECT(Windows 用 unbuffered)
--      table_open_cache             = 4000
--      innodb_log_file_size         = 512M
--    在线验证(不用重启,MySQL 5.7+ 支持动态调整;确认机器内存够再执行):
--      SET GLOBAL innodb_buffer_pool_size = 2 * 1024 * 1024 * 1024;
--      然后重跑 0.3 的 count(*):应从 ~10 s 掉到几十~一两百 ms。
--    这一步做完,第 2、3 步的收益会进一步放大(索引页全在内存,27 ms 还能再降)。
 
 
-- -------------------------------------------------------------------------------------
-- 6. 验证(改完逐条跑)
-- -------------------------------------------------------------------------------------
-- 6.1 单分支是否走覆盖索引(期望 key = idx_patitem_ward/dept,Extra 含 Using index,
--     没有 Using filesort,也没有 Using where 之外的回表)
EXPLAIN
SELECT id
FROM service_subtask
WHERE del_flag = '0'
  AND orgid = '20001001' AND campusid = '30001002'
  AND service_type = '2' AND visit_count = 1
  AND leavehospitaldistrictcode IN ('40003309')
ORDER BY endtime DESC
LIMIT 10;
 
-- 6.2 改写后整体耗时(期望 30 ms 量级,而不是 3000 ms)
EXPLAIN ANALYZE
SELECT id FROM (
    (SELECT id, endtime FROM service_subtask
     WHERE del_flag='0' AND orgid='20001001' AND campusid='30001002'
       AND service_type='2' AND visit_count=1 AND leavehospitaldistrictcode IN ('40003309')
     ORDER BY endtime DESC LIMIT 10)
    UNION
    (SELECT id, endtime FROM service_subtask
     WHERE del_flag='0' AND orgid='20001001' AND campusid='30001002'
       AND service_type='2' AND visit_count=1 AND leaveldeptcode IN ('40003307')
     ORDER BY endtime DESC LIMIT 10)
) t ORDER BY endtime DESC LIMIT 10;
 
-- 6.3 收尾
ANALYZE TABLE service_subtask;
ANALYZE TABLE pat_archive;
 
 
-- -------------------------------------------------------------------------------------
-- 7. 回滚
-- -------------------------------------------------------------------------------------
-- ALTER TABLE service_subtask DROP INDEX idx_patitem_ward;
-- ALTER TABLE service_subtask DROP INDEX idx_patitem_dept;
-- ALTER TABLE pat_archive   DROP INDEX idx_pat_archive_patientno;
-- (SQL / 代码改写回滚直接还原 mapper 即可;参数回滚把 innodb_buffer_pool_size 调回原值)