FIND_IN_SET使用指南:场景、优缺点与MySQL优化策略

0 阅读6分钟

一、什么情况下可以用(有限场景)

1. 静态配置表/字典表

-- 系统配置表,数据极少更新
CREATE TABLE sys_config (
    config_key VARCHAR(50),
    allowed_users VARCHAR(255)  -- "admin,user,guest"
);

-- 低频查询:权限检查
SELECT * FROM sys_config 
WHERE FIND_IN_SET('admin', allowed_users) > 0;
-- ✅ 可接受:配置表通常<1000行

2. 管理后台查询

-- 管理员手动查询,不频繁
SELECT * FROM audit_log 
WHERE FIND_IN_SET('SUCCESS,FAILED', status_list) > 0
LIMIT 100;  -- 必须加LIMIT

3. ETL/报表离线处理

-- 数据仓库,夜间批处理
INSERT INTO daily_report
SELECT * FROM raw_data
WHERE FIND_IN_SET('199', category_path) > 0;
-- ✅ 可接受:离线任务,不要求实时

二、核心优缺点分析

优点

1. 实现简单,SQL直观
2. 避免JOIN操作
3. 保持数据紧凑
4. 查询条件灵活

缺点

1. 性能杀手 - 无法使用索引
2. 数据一致性难保证
3. 查询功能有限
4. 维护困难
5. 类型转换问题

三、如果已经在用,如何优化?(MySQL特供方案)

紧急优化方案(不改表结构)

1. 前置过滤条件法

-- ❌ 原查询(全表扫描)
SELECT * FROM orders 
WHERE FIND_IN_SET('199', duty_persons) > 0;

-- ✅ 优化后(先用索引过滤)
SELECT * FROM orders 
WHERE status = 'ACTIVE'                     -- 状态索引
  AND YEAR(created_at) = 2024               -- 时间范围
  AND FIND_IN_SET('199', duty_persons) > 0; -- 最后执行

2. 生成列+索引法(MySQL 5.7+)

-- 1. 为常用搜索值创建生成列
ALTER TABLE orders
ADD COLUMN has_duty_199 TINYINT(1) 
GENERATED ALWAYS AS (FIND_IN_SET('199', duty_persons) > 0) STORED,
ADD COLUMN has_duty_200 TINYINT(1) 
GENERATED ALWAYS AS (FIND_IN_SET('200', duty_persons) > 0) STORED,
ADD COLUMN has_duty_201 TINYINT(1) 
GENERATED ALWAYS AS (FIND_IN_SET('201', duty_persons) > 0) STORED;

-- 2. 创建索引
CREATE INDEX idx_has_duty_199 ON orders(has_duty_199, status);
CREATE INDEX idx_has_duty_200 ON orders(has_duty_200, created_at);
CREATE INDEX idx_composite ON orders(has_duty_199, has_duty_200, status);

-- 3. 查询使用生成列
SELECT * FROM orders 
WHERE has_duty_199 = 1 
  AND status = 'ACTIVE';

3. 前缀索引优化

-- 为逗号分隔字段创建前缀索引
CREATE INDEX idx_duty_prefix ON orders(duty_persons(20));

-- 结合LIKE前缀匹配
SELECT * FROM orders 
WHERE duty_persons LIKE '199,%'        -- 在开头
   OR duty_persons LIKE '%,199,%'      -- 在中间  
   OR duty_persons LIKE '%,199'        -- 在结尾
   OR duty_persons = '199'             -- 唯一值
   AND FIND_IN_SET('199', duty_persons) > 0; -- 最终验证

4. 内存表缓存

-- 1. 创建内存表存储热点数据
CREATE TABLE hot_orders (
    id INT PRIMARY KEY,
    duty_persons VARCHAR(255)
) ENGINE=MEMORY;

-- 2. 定期同步热点数据
INSERT INTO hot_orders
SELECT id, duty_persons
FROM orders
WHERE updated_at > DATE_SUB(NOW(), INTERVAL 7 DAY)
  AND status = 'ACTIVE';

-- 3. 查询内存表
SELECT * FROM hot_orders
WHERE FIND_IN_SET('199', duty_persons) > 0;
-- ✅ 内存操作,速度快

查询优化技巧

1. 分页查询优化

-- ❌ 错误分页
SELECT * FROM orders
WHERE FIND_IN_SET('199', duty_persons) > 0
ORDER BY id DESC
LIMIT 100 OFFSET 10000;  -- 越来越慢

-- ✅ 优化分页(基于ID范围)
SELECT * FROM orders
WHERE id < 100000  -- 限制扫描范围
  AND FIND_IN_SET('199', duty_persons) > 0
ORDER BY id DESC
LIMIT 100;

2. 批量查询优化

-- ❌ 循环查询
SELECT * FROM orders WHERE FIND_IN_SET('199', duty_persons) > 0;
SELECT * FROM orders WHERE FIND_IN_SET('200', duty_persons) > 0;
SELECT * FROM orders WHERE FIND_IN_SET('201', duty_persons) > 0;

-- ✅ 合并查询
SELECT * FROM orders
WHERE FIND_IN_SET('199', duty_persons) > 0
   OR FIND_IN_SET('200', duty_persons) > 0
   OR FIND_IN_SET('201', duty_persons) > 0;

3. 结果缓存策略

-- 应用层缓存
SET @cache_key = CONCAT('duty_199_result_', DATE_FORMAT(NOW(), '%Y%m%d'));
SET @result = NULL;

-- 检查缓存
SELECT result INTO @result FROM query_cache 
WHERE cache_key = @cache_key 
  AND expiry_time > NOW();

-- 无缓存则查询
IF @result IS NULL THEN
    SET @result = (
        SELECT GROUP_CONCAT(id) 
        FROM orders 
        WHERE FIND_IN_SET('199', duty_persons) > 0
        LIMIT 1000
    );
    
    -- 写入缓存
    INSERT INTO query_cache VALUES (
        @cache_key, @result, DATE_ADD(NOW(), INTERVAL 1 HOUR)
    );
END IF;

四、MySQL替代方案推荐

方案1:JSON类型(MySQL 5.7+)

-- 1. 使用JSON数组存储
CREATE TABLE orders_json (
    id INT PRIMARY KEY,
    duty_persons JSON  -- 存储[199, 200, 201]
);

-- 2. 插入数据
INSERT INTO orders_json VALUES 
(1, '[199, 200, 201]'),
(2, '[201, 202]');

-- 3. 查询优化
-- 方法A:JSON_CONTAINS
SELECT * FROM orders_json 
WHERE JSON_CONTAINS(duty_persons, '199');

-- 方法B:生成列+索引
ALTER TABLE orders_json
ADD COLUMN duty_persons_array VARCHAR(255)
GENERATED ALWAYS AS (
    REPLACE(REPLACE(JSON_EXTRACT(duty_persons, '$[*]'), '[', ''), ']', '')
) STORED;

CREATE INDEX idx_duty_array ON orders_json(duty_persons_array(50));

方案2:位运算(适合有限选项)

-- 1. 使用BIGINT存储位掩码
CREATE TABLE orders_bitmask (
    id INT PRIMARY KEY,
    duty_mask BIGINT UNSIGNED
);

-- 2. 定义位映射
-- 位0: 人员1 (1 << 0 = 1)
-- 位1: 人员2 (1 << 1 = 2)
-- 位7: 人员8 (1 << 7 = 128)
-- 位n: 人员n+1 (1 << n)

-- 3. 设置人员199(第199位)
UPDATE orders_bitmask 
SET duty_mask = duty_mask | (1 << 198)
WHERE id = 1;

-- 4. 查询包含人员199
SELECT * FROM orders_bitmask
WHERE duty_mask & (1 << 198) > 0;  -- ✅ 高效

方案3:全文索引(MySQL 5.6+)

-- 1. 创建全文索引
ALTER TABLE orders 
ADD FULLTEXT INDEX idx_duty_fulltext (duty_persons);

-- 2. 全文搜索
SELECT * FROM orders
WHERE MATCH(duty_persons) AGAINST('+199' IN BOOLEAN MODE);
-- 注意:适用于文本搜索,不适用于精确匹配

五、监控与性能分析

1. 慢查询监控

-- 查看FIND_IN_SET相关慢查询
SELECT 
    query,
    exec_time,
    rows_examined,
    rows_sent
FROM mysql.slow_log
WHERE query LIKE '%FIND_IN_SET%'
  AND exec_time > 1.0
ORDER BY exec_time DESC
LIMIT 20;

2. 执行计划分析

-- 查看查询计划
EXPLAIN 
SELECT * FROM orders
WHERE FIND_IN_SET('199', duty_persons) > 0
  AND status = 'ACTIVE';

-- 关注:
-- type: ALL = 全表扫描
-- key: NULL = 没用索引
-- rows: 扫描行数

3. 性能影响评估

-- 评估函数调用频率
SELECT 
    DATE_FORMAT(event_time, '%Y-%m-%d %H:00') as hour,
    COUNT(*) as total_queries,
    SUM(CASE WHEN argument LIKE '%FIND_IN_SET%' THEN 1 ELSE 0 END) as find_in_set_calls,
    AVG(CASE WHEN argument LIKE '%FIND_IN_SET%' THEN timer_wait/1000000000 ELSE NULL END) as avg_time_seconds
FROM performance_schema.events_statements_history_long
WHERE db = 'your_database'
GROUP BY DATE_FORMAT(event_time, '%Y-%m-%d %H:00')
ORDER BY hour DESC
LIMIT 24;

六、决策指南

风险评估矩阵

风险等级 | 数据量  | 查询频率 | 响应要求  | 建议措施
-----------------------------------------------------------------
低风险    | < 1万  | < 10次/天| < 1秒    | 监控即可
中风险    | 1-10万 | 10-100次/天| 0.5-2秒 | 需优化
高风险    | > 10万 | > 100次/天| > 2秒   | 立即重构

优化优先级

-- P0(立即处理):核心接口 + 慢查询
WHERE interface_type = '核心功能'
  AND avg_response_time > 2.0
  AND call_count > 1000;

-- P1(本周优化):重要功能 + 性能下降  
WHERE interface_type = '重要功能'
  AND avg_response_time > 1.0
  AND growth_rate > 0.5;  -- 响应时间增长率>50%

-- P2(本月计划):非核心 + 有优化空间
WHERE interface_type = '辅助功能'
  AND avg_response_time > 0.5;

技术选型建议

场景                | 推荐方案               | 备注
---------------------------------------------------------------
权限/角色           | 位运算                  | 选项固定且少
标签系统            | JSON + 生成列          | 灵活扩展
分类/层级           | 关联表                 | 需要复杂查询
搜索功能            | 全文索引               | 文本匹配
配置参数            | 保留FIND_IN_SET        | 数据量极小

最后建议

  1. 新项目:从设计上杜绝逗号分隔存储

  2. 老项目

    • 先加生成列索引临时优化
    • 监控慢查询,优先处理高频热点
    • 业务低峰期逐步重构
  3. 紧急上线:用内存表+缓存兜底

  4. 长期规划:根据业务特征选择最合适的替代方案

记住:FIND_IN_SET的性能问题是指数级恶化的。10万行时可能1秒,100万行时可能就是10秒+。越早处理,成本越低。