做了几年后端开发,大家早晚都会撞上一次这种场景:线上一个订单查询接口,原本几十毫秒返回,某天突然变成两三秒,赶上网购大促那几天更是时不时慢查询告警。运维把慢查询日志甩过来,SQL一看,条件不过是个user_id,数据量也就几千万,怎么就走不动了呢?
这类问题的答案,十有八九落在MySQL索引优化上。索引优化是数据库调优里性价比最高的一块,它不需要你重构表结构,也不用购买更高配置的机器,把索引建对、把SQL改顺,同样的语句往往能从几秒降到几十毫秒。这篇文章不打算堆砌高深理论,我会从索引最底层的结构讲起,结合真实的执行计划排查过程,把“慢SQL如何排查—索引为什么生效—哪些场景会失效—实战怎么优化”这条链路完整串起来。适合刚接触MySQL索引优化的新手、写SQL但没深究过执行计划的开发,以及正在准备面试、想系统梳理索引知识的同学。
1. 一个慢SQL引出的问题:为什么行数不多却要扫全表
1.1 当时那条SQL和数据表
先还原一下我遇到的真实场景。订单表结构大概是这样(已简化):
sql复制CREATE TABLE `order_info` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL,
`user_id` bigint NOT NULL,
`amount` decimal(10,2) NOT NULL,
`status` tinyint NOT NULL DEFAULT '0',
`create_time` datetime NOT NULL,
`pay_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
表中大概有两千万行数据,user_id有50万左右个不同用户。慢查询是这条:
sql复制SELECT * FROM order_info
WHERE user_id = 10001
ORDER BY create_time DESC
LIMIT 10;
最让人意外的地方在于:user_id字段上明明建了索引idx_user_id,可执行计划显示扫描的行数高达八十多万行,还出现了Using filesort。换句话说,MySQL没有通过user_id索引直接定位到那几行,而是扫了大量数据再去排序。
1.2 数据量没到“大”的程度,问题出在哪
两千万行在MySQL里并不算特别夸张,InnoDB完全可以支撑。问题不在于表多大,而在于这条查询的扫描方式让量变发生了质变。
为了说清这条SQL为什么会走成全表扫描,需要先理解MySQL执行一次查询的基本流程:解析SQL、生成执行计划、按照执行计划去InnoDB存储引擎读取数据。执行计划里有一个关键环节叫row estimate,也就是优化器会估算每种方案要扫描多少行,然后选择它认为代价最小的方案。
当user_id = 10001这个条件过滤性很差的时候——比如这个用户下了几十万单——MySQL优化器一算,走idx_user_id要回表几十万次,还不如直接全表扫描来得省事。这个判断本身没有错,错就错在SQL写法没有给优化器提供更好的选择。
1.3 索引优化解决的其实是一个成本和选择问题
索引优化的本质,是帮助优化器用更少的IO、更小的代价拿到命中的数据。在InnoDB中,数据是按B+树组织的,每个节点对应一个16KB的页。我们要做的就是让查询能走上一棵高度足够低、节点足够紧凑的B+树,而不是在一个庞大的页链表上从头扫到尾。
这条SQL最终应该怎么优化,我先留个悬念,到实战案例章节再展开。这里先铺垫一个观念:**排查慢查询不能只看“有没有索引”,还得看“索引有没有被用上”以及“用上之后代价是不是足够小”。**这个观念会贯穿整篇文章,下面几章逐个展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引为什么能快:从B+树到磁盘IO的底层逻辑
不把索引的底层机制搞清楚,后面所有的优化手段都只是背结论。这一章我尽量用通俗的话把B+树讲透。
2.1 先从“页”说起:16KB的读写单位
InnoDB读取磁盘的最小单位不是一行记录,而是一个页(page),默认大小16KB。也就是说,哪怕你只需要一行数据,MySQL也会把这个16KB的页整个从磁盘读进内存。
这带来了一个重要推论:一个页里能装多少数据行,直接决定了需要读多少个页。假设一行数据平均1KB,一个页能装16行;如果要扫描1万行,大约要读625个页。而如果是走索引,通过主键定位到某一行,可能只需要从根节点往下读2~3个索引页,外加1个数据页,也就是总共3~4次磁盘IO。
索引之所以快,就是把“读多少页”这个问题从“接近全表页数”压缩到了“树的高度+1”。
2.2 为什么是B+树,而不是哈希表、红黑树、B树
很多人背书能说出“MySQL用B+树”,但被问到“为什么”就卡壳。这里给一个比较完整的对比视角。
哈希索引:等值查询的时间复杂度是O(1),确实能做到极快。但哈希结构天然无序,无法支持范围查询、不能排序,也不支持前缀匹配。大部分业务SQL都不是纯粹的等值条件,ORDER BY、BETWEEN、LIKE 'abc%'太常见了。所以InnoDB默认的索引结构不能选哈希。
红黑树:它是平衡二叉搜索树,高度约log2(N)。两千万行数据,树高大约是25层。这个高度看起来不高,问题在于每一层的节点都可能不在同一个磁盘页里,查找一个叶子节点的数据,可能要经历20多次磁盘IO,磁盘IO每次都是毫秒级,这个开销完全扛不住。
B树:B树每个节点可以存储多个key和对应的数据,树高比红黑树低很多,看似可行。但B树有一个痛点:所有节点都存数据。这意味着中间层的节点也会占据比较大的磁盘空间,一个页能装的key数量明显变少,树的高度就压不下来;更关键的是,如果做范围查询,B树需要在中序遍历过程中反复在中间节点和叶子节点之间跳转,顺序读取的局部性很差。
B+树:把B树的痛点全部解决掉了:
- 非叶子节点只存索引键,不存数据。一个16KB页能存的key数量大幅上升,树高可以控制在3~4层。两千万数据量,从根到叶大约只需要3次磁盘IO。
- 叶子节点通过双向链表串联,天然支持范围扫描和排序。每次从第一个命中叶子开始,沿着链表顺序往后读就行,磁盘预读命中率高。
- 所有数据只存一份在叶子节点,查询路径固定,性能稳定。
这里顺手算一个面试常考的数字:假设主键是bigint(8字节),加上指向子节点的指针(约6字节),一个页能装大约16KB/14B≈1170个索引键。三层B+树(根+一层中间节点+叶子)大约能支撑1170×1170×16≈2190万行数据,而且根节点常驻内存,实际查询只需要读2次磁盘IO。这个数字能解释为什么大部分单表数据量在2000万以内时,走主键查询几乎秒回。
2.3 聚簇索引和二级索引:回表的代价从哪来
InnoDB的每张表都有一棵主键索引树,这棵树被称为聚簇索引。聚簇索引的叶子节点直接存储完整的行数据。换句话说,数据就是索引,索引就是数据。
而我们在user_id、create_time等字段上建的索引,叫做二级索引(也叫辅助索引)。二级索引的叶子节点存储的是索引键和主键值,并不存储整行数据。所以通过二级索引查一行数据,通常需要两步:先在二级索引树上找到主键值,再到聚簇索引树上按主键找整行,这个第二步就叫“回表”。
回表本身不是错误,它是InnoDB保证数据只存一份的代价。但如果某条SQL通过二级索引命中了大量主键,而每一条都需要回表,比如上面那条user_id=10001的SQL,假设命中了50万个主键,那就意味着50万次回表——在这种情况下,优化器不选这个索引可能反而是对的。
后面会讲到的覆盖索引,本质上就是想办法让二级索引“自给自足”,不需要回表,直接把查询需要的列都塞进索引里。
3. 用EXPLAIN把执行计划拆开看:索引到底走没走
3.1 EXPLAIN的基本使用和核心列
排查MySQL慢查询,第一步永远是EXPLAIN。它不会真正执行SQL,而是让优化器输出一份执行计划。
sql复制EXPLAIN SELECT * FROM order_info WHERE user_id = 10001 ORDER BY create_time DESC LIMIT 10;
输出结果里,我平时最关注这几列:
| 列名 | 核心含义 | 重点关注值 |
|---|---|---|
| type | 访问类型,效率从好到差排列 | system > const > eq_ref > ref > range > index > ALL |
| key | 实际用到的索引 | 如果为NULL,说明没走索引 |
| rows | 估算需要扫描的行数 | 和实际数据量偏差越大,越需要警惕 |
| Extra | 附加信息 | Using filesort、Using temporary、Using index等 |
顺便提一句,MySQL 8.0.16之后还有EXPLAIN ANALYZE,它会把SQL真正执行一遍,并输出每个步骤实际耗时的profile。排查线上问题时,这个工具的定位更精准,后面实战案例会用到。
3.2 最常见的三种访问类型:ALL、index、range
ALL(全表扫描):type为ALL时,优化器认为整张表扫一遍的代价最小。常见于表很小(比如几百行)、查询条件过滤性太差、或者索引被函数/类型转换破坏的场景。全表扫描最可怕的地方不是IO多,而是它和业务数据量呈线性关系——数据翻一倍,查询就要多花一倍时间。
index(全索引扫描):type为index时,MySQL会遍历整个二级索引树。看起来比全表扫描好一些,因为索引树比聚簇索引树小,但如果查询需要回表,性能可能还不如全表扫描。
range(索引范围扫描):这个是我们希望经常看到的类型。出现在WHERE里有=、BETWEEN、IN、LIKE前缀匹配等条件时,MySQL能利用索引定位到范围的起点和终点,只扫描这个区间。
3.3 Extra列的几个关键信号
Extra列往往比type更能说明问题,我遇到过不少type显示range但SQL依然很慢的情况,问题就藏在Extra里。
Using filesort:文件排序,意味着ORDER BY的列没有走索引或者走了索引但顺序不匹配。MySQL会在内存或临时文件中把结果集排序。不是不能用,但一旦排序的数据量大,代价会非常高。遇到这个关键字,优先考虑让ORDER BY的字段参与到联合索引中,让索引天然有序。
Using temporary:临时表,一般出现在GROUP BY、DISTINCT、UNION这类操作中。和文件排序一样,也是数据量一大就崩。
Using index:这就是覆盖索引的标志。查询涉及的列全部在索引树里,不需要回表。Extra里出现Using index,通常意味着这条SQL已经相当理想了。
Using index condition:索引下推(ICP)工作的标志,后面会详细讲。它表明MySQL先把索引里能过滤的条件在存储引擎层过滤掉,减少回表次数。
4. 联合索引的最左前缀、覆盖索引、索引下推:三个高频优化手段
这一章开始进入实战区。上面几条只说了“怎么读执行计划”,这里讲“怎么利用索引机制写出更优的SQL”。
4.1 最左前缀,联合索引的核心规则
联合索引本质上是一棵“按多个字段依次排序”的B+树。以(user_id, create_time)为例,它先按user_id排序,user_id相同的再按create_time排序。这决定了一个规则:查询条件必须命中联合索引的最左列,索引才可能被用上。
具体来说:
- 条件有user_id,可以走索引。
- 条件有user_id和create_time,可以走索引,而且排序的顺序可能直接命中。
- 条件只有create_time,索引直接失效,因为索引树最左边的排序依据是user_id,只给create_time时无法定位。
- 条件有user_id和一个范围查询(比如user_id=10001 AND create_time BETWEEN...),user_id等值命中,create_time可以走范围,此时索引还能用。
注意一个细节:如果最左列是范围查询,比如WHERE create_time > '2023-01-01' AND user_id = 10001,那么后续列可能无法继续利用索引。MySQL对联合索引的匹配原则是:遇到范围条件就停止匹配后续列。这也是为什么我们通常把等值条件写在前面、范围条件写在最后面。
4.2 覆盖索引:让二级索引“自给自足”
回表是性能杀手,最直接的解决办法就是不要回表。如果一条查询需要的所有列都出现在某个二级索引的key中,MySQL直接扫描这棵索引树就能返回数据,这就是覆盖索引。
举例来说,业务上经常需要查用户最近订单的某个字段,如果SQL是:
sql复制SELECT user_id, status, amount FROM order_info
WHERE user_id = 10001 AND create_time > '2023-01-01';
那么可以设计这样一个索引:
sql复制ALTER TABLE order_info ADD INDEX idx_user_time_status (user_id, create_time, status, amount);
查询的列user_id、create_time、status、amount全部在索引树里,Extra会显示Using index,连回表都省了。这种优化在报表类、统计类SQL里特别有效,因为这类SQL通常只需要少数几个字段,而不是整行数据。
注意覆盖索引不是万能的,索引字段越多,写入代价和存储空间都会上升。用多少建多少,别为了覆盖而去塞一堆用不到的列。
4.3 索引下推(ICP):从5.6版本开始默默发力的特性
索引下推(Index Condition Pushdown)是MySQL 5.6引入的优化。简单理解:在没有ICP之前,MySQL用联合索引找到一批二级索引记录后,要拿主键去回表,再在聚簇索引上判断WHERE里的其他条件;而启用了ICP之后,对于那些不涉及非索引列的条件,可以直接在存储引擎层用索引树上的字段先过滤掉,再回表。
举个例子,联合索引是(user_id, status),查询是:
sql复制SELECT * FROM order_info
WHERE user_id = 10001 AND status = 1;
没有ICP时,MySQL会扫出所有user_id=10001的索引项,逐条回表,再判断status。有了ICP,MySQL在二级索引树上就能判断status=1,减少大量回表。EXPLAIN里Extra列看到Using index condition就是它在工作。
ICP对开发者的要求很低——它默认开启,不需要额外配置。但了解它的存在,能帮助你在设计联合索引时想明白:**哪些过滤条件放在索引里是有收益的,哪些放进去没用。**那些放在索引里、又能在存储引擎层直接过滤掉的条件,往往就是能触发ICP的高价值字段。
5. 索引失效的典型场景和解决思路:一把尺子量到底
网上有很多“索引失效大全”,但单靠死记硬背效果差,因为场景层出不穷。我建议换个角度:把每个失效场景还原成MySQL的决策逻辑,理解了它,你根本不用背。
5.1 对索引列做计算或函数处理:最常见也最隐蔽
sql复制SELECT * FROM order_info WHERE YEAR(create_time) = 2023;
这条SQL看起来正常,但create_time上的索引不会生效。原因很简单:索引树上存的是原始的create_time值,你的查询条件是YEAR(create_time)的计算结果,MySQL无法直接通过索引快速找到“它的年份等于2023”的记录,只能把每行的create_time取出来算一遍。
解决办法是改写条件,让比较发生在“原始值”上:
sql复制SELECT * FROM order_info
WHERE create_time >= '2023-01-01' AND create_time < '2024-01-01';
这类改写应该是日常优化SQL时的条件反射。
5.2 隐式类型转换:MySQL帮你转了,但代价你出
字段是varchar类型,查询时却传了数字:
sql复制SELECT * FROM user WHERE phone = 13800138000;
MySQL会把字符串列转换成数字来比较,这个转换过程发生在每一行上,索引自然失效。反过来,如果字段是bigint,查询条件传字符串'10001',MySQL通常会尝试把字符串转成数字,这种情况反而可能不影响索引。
调试这类问题的技巧:对SQL里的条件值主动做一下CAST,或者用EXPLAIN看type是否从ref变成了ALL。一旦发现,把条件值改成与字段类型一致的类型,问题通常立刻消失。
5.3 LIKE前缀模糊:范围查询的前世今生
sql复制SELECT * FROM order_info WHERE order_no LIKE '%20231201%';
前缀带%的模糊查询,无法利用B+树的有序性定位起点,因为匹配位置可能在字符串中间的任意位置。但如果是'20231201%'这种前缀匹配,B+树就可以利用字符串的顺序性快速定位,索引是能用的。
这是B+树的数据结构决定的,理解它比记住“前缀不能带%”更可靠。
5.4 OR连接非索引列:一个OR毁掉整条SQL
sql复制SELECT * FROM order_info WHERE user_id = 10001 OR status = 1;
如果status上没有索引,MySQL必须找到所有user_id=10001的记录,再找到所有status=1的记录,然后合并。问题在于,如果status没有索引,MySQL只能全表扫描status这一列,这一扫描就把整个查询拖垮了。优化思路:给status建索引,或者把OR拆成UNION ALL的两条SQL。
5.5 优化器认为索引代价太大:和数据分布有关
这是最容易被忽略的一类:SQL写法没问题,索引也存在,但优化器就是不走索引。原因可能是指定条件下的数据记录数占表总行数比例太高,比如一张只有1万行的表,某字段的某个值占了8000行,优化器会判断全表扫描更划算。
这类情况没什么SQL能解的魔法,应对方向是:重新审视查询是否真的需要返回这么大数据量,或者考虑分库分表、引入汇总表。索引只是工具,不是银弹。
下面用一张表把这几个典型场景和解决路径收个尾:
| 失效原因 | 典型SQL | 根因 | 优化方向 |
|---|---|---|---|
| 函数包裹索引列 | WHERE YEAR(create_time)=2023 | 索引树无法按计算结果定位 | 改写为范围条件 |
| 隐式类型转换 | WHERE phone=138... | 类型不一致触发逐行转换 | 条件值改为与字段一致 |
| 前缀模糊匹配 | LIKE '%xxx%' | 无法利用B+树有序性 | 改用前缀匹配或全文索引 |
| OR连接无索引列 | WHERE a=1 OR b=2 | 无索引分支被迫全表扫 | 给b加索引或UNION拆分 |
| 过滤性太差 | WHERE status=1(占比90%) | 回表代价高于全表扫 | 重建SQL或引入汇总表 |
6. 一次完整实战:订单分页查询从8秒到50毫秒的优化过程
前面几章一直在讲理论、看执行计划,这一章把整个优化流程串起来,从发现问题、定位瓶颈、设计索引到验证效果,完整走一遍。
6.1 问题SQL与现状分析
回到文章开头的场景。线上分页接口用的是这条SQL:
sql复制SELECT * FROM order_info
WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31'
ORDER BY id
LIMIT 100000, 20;
这是非常典型的“深分页”写法。页面越往后翻,LIMIT的偏移量越大,MySQL必须先把前10万行全部查出来丢弃,再取第100001到100020行。数据量一大,这条SQL在线上跑到了8秒左右,直接触发慢查询告警。
先用EXPLAIN看一下:
sql复制EXPLAIN SELECT * FROM order_info
WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31'
ORDER BY id
LIMIT 100000, 20;
执行计划显示type=range,key=idx_create_time,rows预估约200万行,Extra里没有明显的Using filesort,因为ORDER BY id直接利用主键排序。问题很快就清楚了:**优化器在create_time索引上扫出了约200万条记录,然后为了LIMIT 100000, 20,把前10万条一条条跳过。**这个“跳过”的过程发生在server层,索引本身没法快进。
6.2 快速定位瓶颈:从执行耗时分布看
对这个SQL做EXPLAIN ANALYZE:
sql复制EXPLAIN ANALYZE
SELECT * FROM order_info
WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31'
ORDER BY id
LIMIT 100000, 20;
输出里能看到几个关键信息:
- 扫描范围:约200万行数据在索引范围内被读取。
- 回表次数:这200万行都要回聚簇索引取整行数据。
- LIMIT裁剪之前,临时累积了10万条无效记录。
瓶颈在于“扫描+回表+丢弃”这一长串操作。分页越深,无效劳动越多。
6.3 优化方案1:延迟关联,先缩小回表范围
延迟关联(deferred join)的核心思路是:先用覆盖索引把需要的主键id找出来,再用id去回表取完整行。因为回表次数变少了,整体的IO量也随之下降。
改写后的SQL:
sql复制SELECT t.* FROM order_info t
INNER JOIN (
SELECT id FROM order_info
WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31'
ORDER BY id
LIMIT 100000, 20
) tmp ON t.id = tmp.id;
子查询只查id,如果id本身是主键,那么这个子查询根本不需要回表(因为主键直接存在于二级索引叶子节点中),可以快速定位到需要的那20个id,再回到聚簇索引取20行完整数据。
实测效果:这个方案把耗时从8秒压到了200毫秒以内。但它并没有彻底解决LIMIT 100000本身带来的“扫描并丢弃”开销,只是把这段开销从“扫描+回表”降低到“只扫描索引”。如果分页深度继续加大,比如LIMIT 500000,耗时还是会缓慢上升。
6.4 优化方案2:基于游标的分页,直击深分页痛点
深分页的根治办法通常是改造分页方式:不用偏移量,而是用“上一页最后一条记录的主键/时间”来做游标。
比如前端翻页时把当前页最后一条记录的id传回来,SQL改成:
sql复制SELECT * FROM order_info
WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31'
AND id > 上一页最后一条id
ORDER BY id
LIMIT 20;
这条SQL不需要扫描并丢弃前面的10万行,走主键索引直接定位到id > 100000的位置,只取20行。理论上,无论翻到第几页,耗时都稳定在几十毫秒级别。
我在线上实测的数据:优化后P95耗时稳定在50毫秒左右,相比最初的8秒,提升了超过两个数量级。而且数据量继续增长或者翻页更深,查询时间也不会明显恶化。这个方案唯一的限制是,它要求排序字段必须有一个可比较的游标字段(比如自增主键、时间戳)。
6.5 过程中踩过的几个坑
这个案例里我踩过两个比较典型的坑,写出来供参考。
第一个坑是只给ORDER BY的字段加索引,忽略了WHERE条件。初始版本我在create_time上建了单列索引,执行计划看起来走了索引,但rows依然预估有200万。后来意识到,单独的ORDER BY字段无法解决条件过滤后的宽范围扫描,必须让WHERE的过滤条件和ORDER BY的排序同时命中同一个联合索引,才能把这棵索引树的优势利用透。
第二个坑是LIMIT的分页深度和接口调用频率不匹配。优化完延迟关联后,我以为任务结束了,结果后来数据分析团队做报表时用同样的接口拉数据,每5分钟拉一次,每次都翻到几百页开外。这时候延迟关联的优势几乎被稀释干净,还是得换游标分页。有些方案在一个负载模型下很优秀,换一个负载模型就得重新评估。
7. 从单条SQL优化到全局:索引设计、冗余管理和监控
7.1 一个联合索引的设计次序:等值条件优先、范围条件靠后
通过前面的联合索引原理,可以提炼一个设计索引字段顺序的通用规则:
- 先把等值过滤的字段放前面,比如user_id = 10001。
- 再把范围查询字段放后面,比如create_time BETWEEN...
- 如果还有ORDER BY字段,尝试把它放在最后一个或多个等值字段之后,让索引直接有序。
- 若查询中覆盖到了多个列,不要把所有列都塞进索引,优先考虑高选择性的列。
这套规则不是金科玉律,但它能覆盖绝大多数业务查询。在实际设计索引时,把高频SQL对应的执行计划打开,按这套规则逐个调整字段顺序,比盲目建一堆单列索引有效得多。
7.2 冗余索引与重复索引:建索引不是越多越好
线上环境经常看到一张表上有五六个索引,其中两个索引的前缀还完全一样。比如:
sql复制KEY idx_user_id (user_id),
KEY idx_user_id_status (user_id, status)
idx_user_id其实是idx_user_id_status左前缀的真子集。在InnoDB中,这两个索引都独立存在,每次写入都要同时维护两棵B+树。这就是典型的冗余索引,读写都会产生额外开销。
排查冗余索引可以查information_schema.statistics表,或者用Percona Toolkit里的pt-duplicate-key-checker工具。发现冗余索引后,优先删除从左前缀角度完全重复的那个,两个索引的查询能力并不会因此受到损失。
7.3 索引选择性的取舍:不是所有字段都值得建索引
索引选择性 = 去重后的值数量 / 总行数。选择性越接近1,索引区分度越高,扫描行数越少。
以性别字段举例,只有两个值,选择性约等于2/总行数,非常低。在性别字段上建索引,通常捞不到什么好处,反而增加写入成本。但如果是个订单号字段,每条记录都不同,选择性接近1,索引才能高效定位。
不过这里有个例外:某些字段虽然单列选择性低,但放在联合索引前几列时,如果查询是等值条件(比如status=1),它依然能帮我们大幅缩小范围,只是后续列的匹配能力会受影响。取舍的核心还是看业务查询怎么用。
7.4 索引碎片与统计信息:日常维护不可省
B+树在长期删除、更新操作后会产生空洞,导致索引页利用率降低、IO量上升。InnoDB的索引维护机制一般会在后台自动
