分页查询慢,十有八九不是慢在LIMIT,而是慢在count。这是我在优化过无数个后台管理列表之后得出的结论。很多人一遇到分页慢就盯着那条SELECT ... LIMIT 10反复抠索引,却忽略了一个事实:你的页面加载一次,实际上数据库执行了两条SQL——一条查当前页数据,另一条查总条数。而且两件事都很慢的话,你的接口就是慢上加慢。
我见过太多项目把这个问题归咎于“框架默认行为”,MyBatis-Plus分页插件这么写、若依也是这么写,于是大家就觉得这是天经地义的。但实际上,这两条SQL完全有合并成一条的优化空间。今天这篇不聊虚的,直接拆解count和分页为什么慢、怎么合并、合并之后有什么坑、哪些场景不适合合并,把我实际踩过的坑和优化过的线上案例都摊开来讲。
1. 分页慢的根源:count和LIMIT各自都在做什么
1.1 count(*)不是“数一下”那么简单
很多刚接触SQL优化的人有个误区,觉得SELECT COUNT(*) FROM t就是把表里的行数“记”了一下,应该是个O(1)操作。但你看一下MySQL的执行计划就会发现,这条语句无论如何都会选择一个索引去做全索引扫描,走的是B+树的遍历逻辑。
为什么不能像Redis一样直接读一个count值?因为InnoDB的事务隔离级别默认是REPEATABLE READ,每个事务看到的行数可能不一样。为了支持MVCC,InnoDB不能像MyISAM那样在表头维护一个精确的总行数。所以每次count,本质上都必须实时把索引树过一遍,数出当前可见的行数。数据量大、索引树深的时候,这个遍历的成本相当可观。
count还有一个隐性的细节:MySQL优化器会选择一个“最小的索引”来扫。什么意思?如果表里有一个二级索引比主键索引小,优化器就会优先走那个二级索引。因为二级索引的叶子节点只存索引字段和主键值,同样大小的数据页能放下更多条目,扫描的页数更少,IO也少。
我再给你一个直观的数字。假设一张500万行的订单表,主键是BIGINT类型占8个字节,二级索引里再加一个8字节的字段,那么二级索引每页(默认16KB)大概能放下约1000条记录。扫描500万行,大概需要扫5000个数据页。如果走聚簇索引,主键加上行数据,一页放的记录数可能只剩下300条不到,页数直接翻倍。所以“走小索引”的优化效果是实打实的。
1.2 offset越大,LIMIT越慢,这是最反直觉的地方
分页查询的经典写法是:
sql复制SELECT column_list
FROM t
WHERE status = 1
ORDER BY id DESC
LIMIT 10 OFFSET 200000;
表面上看这条SQL是“取10条”,但它真正的执行过程是:从B+树里找到满足status = 1的第一条记录,然后沿着叶子节点链表一条一条向后扫,扫满200010条之后,再把前面200000条全部丢弃,只返回最后10条。也就是说,OFFSET越大,数据库扫描和丢弃的数据量就越大,但返回给用户的始终只有10条。
这就好比你翻书找内容,翻了200页才找到目标页,但这200页你每一页都得用眼睛扫一遍,不能跳过。数据到了磁盘上,它没有“直接定位到第20万行”的物理能力,只能靠顺序扫描来模拟“跳页”。
所以在超大OFFSET的分页场景下,真正让人不寒而栗的不是LIMIT,而是那个不断变大的OFFSET。这也是为什么“下一页很慢、上一页很快”这类问题那么多见的根本原因。
1.3 两条SQL的叠加效应:网络往返和重复扫描
到这里你就明白了,分页接口慢,本质上干了两次“大扫描”:
- count阶段:为了拿到总条数,全索引扫一遍。
- limit阶段:为了拿到第N页数据,扫到第N*页大小条记录再丢弃。
这两次扫描是各自独立的,谁也不帮谁。等于你为了一个列表页,把一张大表全量扫了两遍。等OFFSET继续变大,这个成本还在往上冲。
更要命的是,这两条SQL还产生了两次网络往返。如果你的MySQL和业务服务器不在同一台机器上,每次RTT哪怕是0.5毫秒,看起来问题不大,但真实生产环境里,高并发下连接池、GC、网络抖动都会放大这个延迟。两条变一条,至少能省掉一次内部交互和一次SQL编译解析的开销。
所以“合并count和分页”的核心思路,不是把count干掉,而是让数据库在一次扫描里同时完成“数总数”和“取当前页”两件事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 合并count与分页的方案:窗口函数一把梭
2.1 核心SQL写法
合并count和分页,最直接的技术手段是使用窗口函数COUNT(*) OVER()。这个窗口函数可以在不改变结果集行数的情况下,给每一行附上一个“当前结果集的总行数”。你只需要在LIMIT分页之前把结果集算出来,窗口函数就会把总数算好,LIMIT之后再取其中一小部分,总数依旧保留在每一行上,取一条就能拿到。
核心SQL长这样:
sql复制SELECT
t.id,
t.order_no,
t.amount,
COUNT(*) OVER() AS total
FROM orders t
WHERE t.status = 1
ORDER BY t.id DESC
LIMIT 10 OFFSET 0;
执行一次,你既能拿到当前页的10条数据,又能从任意一行的total字段里读出符合条件的总条数。相比原来的两次查询,API层只需要和数据库交互一次,前端的渲染逻辑也完全不用动,因为你返回的结构依然是{ list, total }。
这里有个关键点:窗口函数是在GROUP BY和HAVING之后、ORDER BY和LIMIT之前执行的。也就是说,COUNT(*) OVER()统计的是“没有LIMIT之前”的完整结果集行数,而不是当前页的10行数。所以拿到的total就是符合WHERE条件的所有行数,这正好是前端分页组件需要的那个total。
2.2 窗口函数的执行逻辑和开销
窗口函数会让每一行结果都带着total值,所以如果结果集本身很大(比如50万行都符合条件),那么每一行都要带一个total值返回给临时内存或临时表。这对内存和临时空间是有消耗的。
MySQL官方文档里有说明,窗口函数执行会使用临时表来存放结果,必要时还会在磁盘上创建临时文件。但要注意,这种临时表不是MySQL 8.0里那种优化过的内部临时表,它仍然是一个完整的物化过程。
不过这里也有个优势:只要LIMIT的OFFSET不是太大,优化器有可能只从临时表里拿LIMIT指定的那部分行,而不会把全部结果都物化一遍再丢弃。实测下来,在结果集几十万行、OFFSET很小(比如前几页)的场景下,合并SQL的响应速度通常比两条SQL快不少,因为少了一次全网扫描和一次RTT。
如果你要翻到很深的页,比如OFFSET已经到20万,那么窗口函数+OFFSET依然逃不掉“扫描20万行再丢弃”的宿命,这时候merge方案的优势主要就体现在少了一次count全扫描上。
2.3 不同数据库的兼容性分析
很多人一说窗口函数就想到MySQL 8.0,实际上这个方案在主流数据库里都支持,只是写法上略有差异:
| 数据库 | 是否支持 COUNT(*) OVER() | 分页语法 |
|---|---|---|
| MySQL 8.0+ | 支持 | LIMIT offset, count |
| Oracle | 支持 | FETCH FIRST / ROWNUM |
| PostgreSQL | 支持 | LIMIT ... OFFSET ... |
| SQL Server | 支持 | OFFSET ... FETCH NEXT |
| MySQL 5.7及以下 | 不支持 | 需要变通方案 |
如果你的项目还在用MySQL 5.7,又想用类似方案,可以用一条UNION来模拟。具体思路:第一段正常的LIMIT查询,第二段用SELECT COUNT(*)当一行结果。然后外层包一层,把两段结果合并。代码可以这样写:
sql复制SELECT * FROM (
SELECT t.id, t.order_no, t.amount, NULL AS total
FROM orders t
WHERE t.status = 1
ORDER BY t.id DESC
LIMIT 10 OFFSET 0
) page
UNION ALL
SELECT 0, NULL, NULL, COUNT(*)
FROM orders
WHERE status = 1;
这种写法在MySQL 5.7里也能用,但要注意UNION的列必须对齐。实际返回的两行里,第一行到第N行是分页数据,最后一行total有值、其余字段为NULL。业务代码里需要做一层简单的解析,把最后一行单独的total取出来。本质上还是“表面一条SQL,内里两次扫描”的投机取巧,性能收益没有窗口函数那种一次扫描那么多,但至少API层只发了一次请求。
3. 实操改造:从两条SQL到一条SQL的完整过程
3.1 一个真实的改造场景:后台订单列表
我在之前优化过一个订单管理后台的分页接口。订单表大概有380万行,管理员的筛选项一般就是订单状态、下单时间范围、订单号模糊查询。原实现是典型的MyBatis-Plus分页写法:
java复制// 原始代码(两条SQL)
Page<Order> page = new Page<>(current, size);
QueryWrapper<Order> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1)
.between("create_time", startTime, endTime)
.like("order_no", keyword)
.orderByDesc("create_time");
IPage<Order> result = orderMapper.selectPage(page, wrapper);
这段代码执行后会发出两条SQL,一条count,一条limit。问题出在数据量上来之后,这两条都很慢。尤其当管理员选择的时间范围跨了两个月,扫描的行数直接飙到几十万,count和limit各扫一遍,接口平均响应时间在1.2秒到1.8秒之间,用户体验很差。
改造时我没有用MyBatis-Plus自带的selectPage,而是自己写了一个自定义Mapper方法。SQL用窗口函数合并:
xml复制<select id="selectPageWithTotal" resultType="OrderWithTotal">
SELECT
o.id,
o.order_no,
o.status,
o.amount,
o.create_time,
COUNT(*) OVER() AS total
FROM orders o
WHERE o.status = #{status}
AND o.create_time BETWEEN #{startTime} AND #{endTime}
<if test="keyword != null and keyword != ''">
AND o.order_no LIKE CONCAT('%', #{keyword}, '%')
</if>
ORDER BY o.create_time DESC
LIMIT #{offset}, #{limit}
</select>
Java的Mapper返回类型改成List<OrderWithTotal>,OrderWithTotal里多加一个total字段。取total的时候直接用第一行数据的total即可,因为窗口函数给每行都加了值。如果当前页没数据,total字段会是NULL,这时前端拿到的total就取0。
改造完之后,接口平均响应时间从1.2秒降到300毫秒左右,提升了4倍多。最大的收益不是单条SQL变快了,而是把原来最耗时的count全索引扫描给省掉了。
3.2 执行计划怎么看
如果你也想验证自己的SQL是否走了更优的路径,执行计划是关键。直接在MySQL里对改造后的SQL执行EXPLAIN VERBOSE,注意版本不同用法不同,MySQL 8.0可以直接:
sql复制EXPLAIN ANALYZE
SELECT
o.id,
o.order_no,
COUNT(*) OVER() AS total
FROM orders o
WHERE o.status = 1
AND o.create_time BETWEEN '2024-01-01' AND '2024-02-01'
ORDER BY o.create_time DESC
LIMIT 10 OFFSET 0;
执行计划里会多出一个WindowAgg节点,这个节点就是窗口函数的计算过程。你要关注的是:
- 访问类型(type)是不是走在了合适的索引上,比如是否用到了
idx_status_create_time这类复合索引。 - 扫描行数是不是明显小于原来的count查询。
- 有没有出现Using temporary和Using filesort,如果出现了,就要评估临时表有多大。
我自己在优化时发现一个有意思的现象:在结果集不算特别大的情况下,WindowAgg节点的速度往往比预想的快。因为MySQL的窗口函数执行器在拿到LIMIT之后会提前终止窗口计算,并不会把所有行都算完。这一点在MySQL 8.0.23之后优化得比较好,8.0.21之前可能的物化开销会更大,所以升级小版本也可能带来意外惊喜。
3.3 框架场景下的改造方法:MyBatis-Plus和若依
很多人用的是MyBatis-Plus自带的分页插件,想合并count和分页时,第一反应是“能不能改插件的源码”。不需要。
MyBatis-Plus的分页插件有这么几个关键点:
PaginationInnerInterceptor默认会先执行count查询,再执行limit查询。- 可以设置
optimizeJoin让count自动去重,但本质上还是两条SQL。 - 如果不想让插件自动执行count,可以设置分页参数里的
searchCount为false,或者把Page的setSearchCount(false)。
然后自己写一个自定义Mapper方法,用窗口函数的方式返回total。前端依然拿Page对象里的total没毛病,因为你可以在Service层自己把total塞进去。
若依框架的PageUtils.startPage()底层用的也是MyBatis-Plus的分页机制,所以你看到若依生成的代码默认是分页插件在管。改造方式完全一样:在SQL里用窗口函数自己算total,在Service层手动setTotal到Page对象。
这里有个要注意的细节:如果你用了PageHelper,它的逻辑是改SQL加count,并且要求Mapper返回值必须是List,而不是IPage,所以窗口函数方案对它也兼容,只是PageHelper的count优化逻辑不会被触发,相当于完全用自己的SQL逻辑。
我个人的建议是:如果项目已经重度依赖分页插件的自动分页,不要全局改造,只挑那些数据量大、确实慢的列表做局部优化。其他中小表继续用默认分页,既省事又不容易引入新坑。
4. 更极致的优化:当count依旧很慢时怎么办
4.1 数据量达到千万级时,count方案要重新评估
合并count和分页,本质上是把两次扫描变成一次扫描,但扫描成本依然存在。当表达到千万级以上,而且你的WHERE条件筛选出来的行数非常大时,窗口函数依然要计算一个非常大的结果集来得到total,这会带来较大的内存和临时表压力。
这时候我建议换一种思路:不要追求“实时的精确total”,而是考虑近似total或缓存total。前端分页组件显示的“共N条”数据,绝大多数场景并不需要每一秒都精确。用户翻到第10页的时候,第5页的total变了一个数字,用户根本感知不到。
近似count可以使用EXPLAIN SELECT COUNT(*) ...得到优化器估算的行数,虽然不精确,但速度极快。它的原理是读取索引的统计信息,而不是真正的扫描数据。实测估算值和实际值偏差从百分之几到百分之几十都可能,但作为分页组件显示足够用。
4.2 Redis缓存total值
如果你不能接受近似值,那可以考虑把精确的count结果缓存到Redis里,设置合理的过期时间。这个方案的重点不是“缓存分页结果”,而是只缓存total。因为页面的实时性要求主要体现在数据列表本身,total是一个统计性的次要数据。
操作逻辑是:
- 第一次请求时,用合并SQL或两条SQL拿到精确total。
- 把total和对应的查询条件(比如status+时间范围+关键词)拼成一个Redis key,设置5到10分钟的过期时间。
- 后续请求直接读缓存,不再执行count逻辑。
- 对于新增数据的场景,可以在数据变更时主动删掉相关缓存key,下次请求再重算。
有个容易踩的坑:Redis缓存total时一定要把查询条件hash进key。我见过有人直接用order:total作为key,结果不同条件都命中同一个缓存,页面显示的总数永远不对。业务上一旦加了新的筛选条件,缓存就全乱了。
java复制// 伪代码参考
public long getOrderTotal(OrderQuery query) {
String key = "order:total:" + digest(query.toString());
Long total = redisTemplate.opsForValue().get(key);
if (total != null) {
return total;
}
long count = orderMapper.selectTotalByCondition(query);
redisTemplate.opsForValue().set(key, count, Duration.ofMinutes(5));
return count;
}
4.3 大OFFSET场景下的终极方案:游标分页
如果你的分页是“点击下一页”的方式,而且列表数据量极大,OFFSET会随着翻页越来越深,那么我强烈建议考虑游标分页(也叫Keyset分页)。这种模式彻底绕开了OFFSET的问题,用WHERE加索引条件直接定位到上一条记录的位置。
具体写法是:
sql复制SELECT id, order_no, create_time
FROM orders
WHERE status = 1
AND create_time < #{lastCreateTime}
ORDER BY create_time DESC
LIMIT 10;
每次翻页时,直接把当前页最后一条记录的create_time(或者id,取决于你的排序字段)传给下一次查询,用索引直接跳到那个位置往后取。因为走的是索引定位,扫描的数据量只跟LIMIT的大小有关,跟翻了多少页完全没有关系。性能极其稳定,且可以完美配合“合并count策略”里的窗口函数,因为结果集被缩小到只有当前页那么大,count窗口函数几乎零成本。
游标分页的缺点是:不能自由跳页,不能显示“共N页”然后让用户直接点第50页。所以它更适合新闻流、动态流、瀑布流这类不需要自由跳页的产品。如果产品经理要求必须支持跳页,游标分页就不太合适了。
4.4 如何判断该用哪种优化策略
我把自己在项目里做决策的经验整理成一个简单的对照表,供你参考:
| 数据量 | 翻页深度 | 业务需求 | 推荐方案 |
|---|---|---|---|
| 小(百万以内) | 较浅(前10页) | 任意跳页 | 窗口函数合并count和LIMIT |
| 中(百万到千万) | 较深 | 任意跳页 | 窗口函数+Redis缓存total |
| 大(千万以上) | 极深 | 只支持翻页 | 游标分页+窗口函数 |
| 大(千万以上) | 极深 | 必须跳页 | 近似count或定时统计total |
这个表不是固定的,核心是在“准确度、性能、产品体验”三者之间做取舍。如果产品对总数不敏感,建议优先砍掉精确count。
5. 常见问题与排查技巧实录
5.1 为什么合并count之后没有变快,甚至更慢
这是最常遇到的疑问。窗口函数合并方案不是万能的,在某些场景下,反而比两条SQL还要慢。我排查过的一个典型案例是:某列表页的WHERE条件很宽,几乎把整张表都捞了出来,原本count走的是二级索引,LIMIT走的也是二级索引,两条SQL各扫各的,速度都不算慢。合并之后,窗口函数需要对整个宽结果集做一次物化,每行都计算一个total,临时表膨胀得很厉害,最后响应时间反而从200毫秒涨到了600毫秒。
结论是:合并方案适合“过滤后结果集不大但OFFSET较深”或“count本身很慢”的场景。如果你的WHERE条件几乎过滤不了数据,这个方案要谨慎使用。优化的前提是先让你了解当前瓶颈在哪,不要盲目套模板。
5.2 count字段加索引到底加在哪
我看到很多人问要不要给count(id)里的id加索引,这是个概念混淆。count的性能瓶颈在“扫描的行数”和“扫描的索引大小”,而不是某个字段有没有索引。应该优先保证WHERE条件里的字段被索引覆盖,而不是count里的字段本身。
比如WHERE status = 1 AND create_time BETWEEN ...,你优先考虑建(status, create_time)的联合索引。如果分页排序是ORDER BY id DESC,而id本身就是主键,那在联合索引里天然有序,就有机会避免filesort。
还有个小技巧:count的时候尽量用COUNT(*),而不是COUNT(字段名)。COUNT(字段名)需要判断字段是否非NULL,多了一层空值判断,而且如果被count的字段长度很大,索引也会变大,扫描成本更高。COUNT(*)在这里不会count所有列,它只是快速统计行数,优化器会选最小的索引来跑。
5.3 分页查询慢能不能用Redis直接缓存整页数据
这是另一个高频问题。Redis缓存整页数据可以,但有几个前置条件:数据整体变化不频繁、查询条件固定、页容量不能太大。比如一个地区的列表页、字典列表页,用Redis缓存是可以的。但订单列表这种高频变更的数据,缓存命中率低下,而且数据一致性难以保证。
我之前试过用Redis缓存订单列表的前100页,每次下单和状态变更都得删除一批相关的缓存key。刚开始还行,后续并发一高,缓存穿透和缓存雪崩的风险依次出现,最后只能放弃。
如果你想用Redis优化分页,我更推荐只缓存total,而不是缓存每页数据。列表数据仍然走数据库,因为数据库索引很擅长取有限的几十条,你硬塞到Redis里并没有收益,反而多了缓存一致性负担。
5.4 OFFET为0时要注意的边界
很多分页组件的默认页码是0,但SQL里通常写成OFFSET 0。OFFSET 0意味着不跳过任何行,数据库直接从第一条开始返回,这是最快的分页场景。优化时不要忽略这种默认状态,如果用户一开始就在第0页,窗口函数方案几乎没有任何压力。
但要注意:OFFSET 1和OFFSET 0逻辑上就差一行,但某些数据库(比如Oracle的ROWNUM分页)很忌讳ROWNUM=1这种写法,容易导致查询计划劣化。MySQL没有这个毛病,但在窗口函数和OFFSET组合时,建议始终把OFFSET设为0开始,不要在代码里做无意义的+1。
5.5 分页组件显示的total和实际数据对不上的问题
这是合并方案里最容易让业务误判的坑。用户看到total显示是5000条,但实际翻到最后一页发现只有十几条数据。这通常是因为当前页数据全部为空,窗口函数不会为不存在的行赋予total值,所以业务代码取total时如果只取第一行,就会拿到NULL,然后你默认赋了一个脏值。
业务逻辑层对total的取法要做空判断。我的习惯是:
java复制if (list == null || list.isEmpty()) {
total = 0;
} else {
total = list.get(0).getTotal();
}
还有一个隐患是SQL里如果带了LIMIT,且LIMIT的偏移量超过了实际数据量,比如用户手动改URL跳到第10000页,那一行都查不到,total也就拿不到。这种情况要额外做一次兜底查询,或者限制最大翻页深度,否则前端分页组件就崩了。
5.6 排序字段没有索引导致的filesort
窗口函数方案里,ORDER BY会直接影响窗口计算顺序。如果你的排序字段没有索引,MySQL会先把结果集全部读出来,做一次filesort,再计算窗口函数,最后再LIMIT。这会让合并SQL在没有索引的情况下变成一场灾难。
举一个我踩过的例子:某接口按create_time倒序分页,但表上的联合索引是(status, create_time),看起来create_time已经在索引里了,但其实SQL里where条件和order by字段的顺序组合不当,优化器没有选择这个联合索引,而是走了全表加filesort。
排查方式还是看执行计划里的Extra列,如果出现Using filesort,优先调整索引顺序,让排序字段和WHERE字段同时被一个联合索引覆盖。如果你改不了索引,那就退回到“两条SQL”的老方案,至少count还能走一个轻量索引。
6. 不止于count和分页:这套思路还能延伸到哪里
6.1 并行SQL优化和慢SQL治理的启发
“合并count和分页”表面上是一个SQL写法的小技巧,但它背后的思想是通用的:尽量减少不必要的数据扫描和多次往返。
我之前处理过一套定时任务,里面有几个大SQL,每跑一次要消费掉整个数据库实例80%的IO。排查之后发现,这些SQL都有同一个问题:把两段不相关逻辑写在同一张临时表里,反复读、反复算。拆开之后按数据量分片,用并行方式处理,整体执行时间下降了一个数量级。这和分页优化里的“合并”和“拆分”其实是同一枚硬币的两面——核心都是减少无效数据访问。
6.2 从执行计划开始,养成慢SQL优化的习惯
慢SQL优化不只是调一条SQL,而是建立一套完整的分析习惯:
- 先拿到完整的执行计划,看访问类型、看扫描行数、看Extra列。
- 再评估索引是否覆盖了所有筛选和排序字段。
- 最后再决定是“合并SQL”还是“改成游标分页”还是“改缓存方案”。
如果跳过前两步直接套优化方案,很容易把一个小索引问题错误地升级成缓存架构问题,反而越搞越复杂。我在团队里带人的时候经常说:SQL优化先问EXPLAIN,不要让SQL盲飞。
6.3 前端分页组件的选型配合
优化SQL只是后端环节,前端分页组件的行为也会影响最终体验。比如ElementUI的el-pagination就是靠后端返回的total来渲染总页数。如果你优化后拿不到total或者total是近似值,前端页面上就会显示一个“约多少条”或者精确值,这没啥关系,但记得和后端约定好total字段的返回格式。
还有一些分页组件支持自定义接口每次只返回下一页的数据(类似游标分页),这类组件和游标分页后端方案是天作之合。如果产品形态是新闻流、评论流,选择这种组件会大大降低后端实现成本。前端也不用再维护当前页码,服务端每次把上一页的最后一个排序字段值回传过来,整个分页链路就是自然的无限滚动。
最后再分享一个小技巧
如果你决定使用窗口函数合并count和分页,别忘了在MySQL连接参数里设置useServerPrepStmts=true和cachePrepStmts=true。这能让你的分页SQL预编译缓存生效,避免频繁同一SQL的解析开销。我刚开始优化时忽略了这点,虽然SQL从两条变成一条,但解析时间占比反而上升了。这两个参数打开之后,单次查询时间又掉了一截。
另外,如果业务允许,给分页接口加上最大OFFSET限制。比如控制器的分页参数校验超过5000就不让查,避免有人恶意用超大OFFSET把数据库压垮。这不算SQL优化,但属于慢SQL治理里很重要的一环。我的建议是技术手段和业务手段结合使用,数据库的稳定性和响应时间是第一位的。
