缓存穿透这个问题,很多后端开发第一步想到的方案就是“查不到就缓存空值”。这个思路本身没有错,也确实能挡住一部分流量,但如果你的系统已经处于高并发场景,或者正在被恶意请求持续攻击,缓存空值只能算及格,离专业还有一段距离。
原因很简单:缓存空值解决的是“单个 key 反复穿透”的问题,而真正的缓存穿透往往是大批量、多 key 维度、甚至带随机化后缀的请求。这种场景下,空值缓存既占内存,又有过期窗口,还会污染缓存语义。所以更准确的判断是:只会用缓存空值,说明还没把穿透治理当成一个系统性问题来对待。
这篇文章会用 Java + Redis + RedisBloom 的组合,完整拆解几种缓存穿透治理方案的实现路径、适用边界和验证方法。内容包括:缓存空值怎么落地、布隆过滤器怎么接入、互斥锁怎么防止并发击穿、限流怎么兜底,以及一套可以在测试环境直接跑通的压测观察流程。看完之后,你应该能清楚判断自己系统该选哪种方案,而不是笼统地只知道“缓存空值”四个字。
1. 核心方案速览
| 方案 | 拦截层次 | 解决的问题 | 实现成本 | 防御强度 |
|---|---|---|---|---|
| 参数校验 | 接口层 | 过滤明显非法的请求 | 极低 | 低 |
| 缓存空值 | 缓存层 | 已查询过的空 key 二次穿透 | 极低 | 一般 |
| 布隆过滤器 | 缓存前置 | 一定不存在的 key 直接拦截 | 中 | 强 |
| 互斥锁 | 缓存回填 | 热点 key 并发击穿数据库 | 中 | 强 |
| 限流降级 | 网关/应用层 | 异常流量兜底保护 | 中 | 强 |
从表格可以看到,不同方案解决的是不同层次的穿透问题。缓存空值处理的是“查过且不存在”的 key,布隆过滤器处理的是“根本不存在”的 key,互斥锁解决的是“存在但缓存过期瞬间被并发打爆”的 key,限流则是最后一道安全网。真正的生产环境,大多数情况下是多个方案叠加使用,而不是只选一个。
2. 缓存穿透的成因、危害与治理边界
2.1 穿透的请求链路
缓存穿透的典型链路是:请求进入系统后,先查缓存,缓存未命中,然后查询数据库,数据库也不存在该记录,于是本次请求返回空结果,并且没有任何写回动作。
单次请求这样做没有问题,问题在于高频重复。如果请求端是脚本或者恶意用户,他们可以伪造大量不存在的 userId、orderId、skuId,每个请求都绕过缓存直接打到数据库。数据库连接数和 CPU 会随之飙升,严重时直接拖垮数据库,进而影响所有依赖这个数据库的业务。
这种攻击的典型特征就是 key 高度随机、总量大、单 key 请求次数少。正因为单 key 请求次数少,缓存空值方案的效果才大打折扣——攻击者可以不断换新 key,让你缓存里的空值越来越多,但数据库被访问的总次数并没有降下来。
2.2 缓存空值方案的四个硬伤
缓存空值的基本做法是:数据库查询结果为空时,仍然把一个空值对象写入缓存,并设置一个较短的过期时间。后面再遇到相同的 key,直接从缓存返回空,不再打数据库。
这个方案的硬伤主要有四个。
第一,内存被无效 key 占据。攻击者一旦构造海量不同 key,Redis 里就会积压大量空值记录,内存占用持续上升。如果空值 TTL 设置过长,Redis 内存淘汰压力会很大。
第二,需要一个短 TTL 的空窗期。为了不让空值长期占内存,TTL 一般设得很短,比如 60 秒。但 TTL 一过,攻击者再次循环请求同一个 key,数据库又会被打到。
第三,缓存语义被污染。缓存中出现大量代表“空”的占位符,业务代码要额外判断“现在拿到的是缓存回填的临时值,还是数据库真正的结果”,很容易在后面的逻辑里埋坑。
第四,无法区分“空结果但合法”和“空结果但异常”。比如数据还没生成、请求参数非法、数据库超时查不到,这些场景全部被空值缓存统一吞掉了,排障难度直线上升。
2.3 什么时候需要升级方案
如果你的系统并发量低、业务 key 数量有限、没有恶意攻击风险,缓存空值加合理的 TTL 完全够用。但出现以下信号时,就应该考虑升级:
- 监控里数据库的 select 查询 QPS 出现不符合业务规律的突刺;
- 接口平均响应时间周期性抖动,且集中在数据库慢查询;
- Redis 内存里出现大量无业务含义的 key;
- 日志里出现大量“不存在数据”的判定分支。
这些信号说明,穿透已经从偶发变成了常态,空值缓存已经无法兜住。
3. 环境准备与前置条件
3.1 基础组件
本文示例基于 Java + Spring Boot + StringRedisTemplate + Jedis,数据库使用 MySQL。实际上换成任何语言和 Redis 客户端,思路都一样。
环境需求如下:
| 组件 | 作用 | 版本建议 |
|---|---|---|
| JDK | 运行 Spring Boot 服务 | 8 或 11 即可 |
| Redis | 缓存与锁的存储 | 6.0 以上 |
| RedisBloom | Redis 布隆过滤器模块 | 2.2 以上 |
| MySQL | 业务数据源 | 按业务现状 |
| 压测工具 | 验证效果 | wrk 或 JMeter |
RedisBloom 模块可以直接下载编译后的 so 文件,在 redis.conf 里通过loadmodule加载。加载完成后,用BF.ADD、BF.EXISTS等命令做一次冒烟测试,确认模块已经生效。
3.2 启动前校验清单
部署前逐项确认:
- Redis 能连通,RedisBloom 模块可用;
- 业务表具备主键索引,联表查询具备复合索引;
- 数据库连接池最大连接数已知,避免压测时无感知打爆;
- 服务具备基本的缓存封装层,不要把 Redis 操作散落在业务代码里;
- 日志链路里能定位到“缓存未命中”“数据库已查”“空值已回填”三个关键节点。
这些前置条件准备完毕,再开始改代码。
4. 缓存空值方案:先用最短路径跑通
4.1 实现逻辑
缓存空值方案的 Java 实现核心思路是:查询用户详情时,如果数据库返回 null,也写入一个标记对象,并设置短 TTL。下面是一段可直接运行的模板代码,表名和 DAO 需要按实际项目替换。
@Service public class UserServiceImpl implements UserService { private static final String USER_CACHE_KEY = "user:detail:"; private static final String USER_NULL_CACHE_KEY = "user:null:"; private static final long NULL_TTL_SECONDS = 60; private static final long DATA_TTL_SECONDS = 300; @Autowired private StringRedisTemplate redisTemplate; @Autowired private UserMapper userMapper; public UserVO getUserById(Long userId) { // 1. 先查正常缓存 String cacheKey = USER_CACHE_KEY + userId; String json = redisTemplate.opsForValue().get(cacheKey); if (json != null) { return JSON.parseObject(json, UserVO.class); } // 2. 查数据库 User user = userMapper.selectById(userId); // 3. 查不到也写缓存,TTL 设置短一点 if (user == null) { redisTemplate.opsForValue() .set(USER_NULL_CACHE_KEY + userId, "EMPTY", NULL_TTL_SECONDS, TimeUnit.SECONDS); return null; } // 4. 回填真实数据缓存 redisTemplate.opsForValue() .set(cacheKey, JSON.toJSONString(user), DATA_TTL_SECONDS, TimeUnit.SECONDS); return convert(user); } }这里有一个容易被忽略的细节:空值 key 和非空 key 必须使用不同的前缀。如果共用同一个 key,用户从不存在变为存在时,业务侧可能读到历史写入的空值标记,导致数据不更新。
4.2 效果验证
代码发布到测试环境后,按下面的步骤验证:
- 连续多次调用一个不存在的 userId 接口;
- 观察第一次查询走了数据库,后续查询是否全部命中 Redis;
- 进入 Redis 客户端,执行
keys user:null:*,确认空值 key 已写入; - 等待 TTL 过期,再次请求同一个 userId,确认数据库再次被查询。
第 4 步验证完成后,你会直观看到空值缓存的“临时有效”特性。它是帮你挡了 60 秒流量,但 60 秒后的下一个请求,数据库依然会被打到。
4.3 这个方案的边界
缓存空值最适合的场景是:热点不集中的内部系统、低频查询的管理后台、或者 key 总数可控的业务。这类场景穿透量本身不大,短 TTL 的空值缓存完全能扛住。
但它不适合高并发的 C 端接口。原因前面已经说过:攻击者能构造无限新 key,空值缓存的内存增长不可控,数据库仍然会被高频查询。测试环境压测时,你可以尝试用脚本生成 10 万个随机不存在的 key 并发请求,观察 Redis 内存和数据库 QPS,结论会很明显。
5. 布隆过滤器:拦截一定不存在的 key
如果说缓存空值是在“查完之后”做补救,布隆过滤器就是在“查询之前”做拦截。原理是把业务里所有可能存在的 key 提前放入一个 bitmap 结构,查询时先判断 key 是否可能存在。返回不存在,那一定不存在;返回可能存在,才继续往下走。
5.1 RedisBloom 初始化
使用 RedisBloom 模块,初始化一个容量为 100 万、误判率 1% 的过滤器:
BF.RESERVE user:bloom 0.01 1000000命令格式为BF.RESERVE key error_rate capacity。容量设置需要按业务预估,误判率越低,占用的内存越大。日常测试可以先用小容量,方便观察效果。
Java 侧初始化代码:
@Configuration public class RedisBloomConfig { private static final String BLOOM_KEY = "user:bloom"; @Value("${redis.host:127.0.0.1}") private String host; @Value("${redis.port:6379}") private int port; @PostConstruct public void initBloom() { try (Jedis jedis = new Jedis(host, port)) { jedis.bfReserve(BLOOM_KEY, 0.01, 1_000_000L); } catch (Exception e) { // 过滤器已存在时会抛出异常,忽略即可 } } }这段代码在服务启动时保证过滤器存在。要注意的是,BF.RESERVE只能调用一次,重复调用会报错,所以实际项目中要加上存在性判断或者忽略异常。
5.2 读路径接入
查询路径调整为:先走布隆过滤器,不存在直接返回,存在才继续查缓存和数据库。
public UserVO getUserByIdWithBloom(Long userId) { // 0. 布隆过滤器前置判断 try (Jedis jedis = new Jedis(host, port)) { boolean maybeExists = jedis.bfExists(BLOOM_KEY, userId.toString()); if (!maybeExists) { return null; } } // 1. 正常缓存逻辑 String cacheKey = USER_CACHE_KEY + userId; String json = redisTemplate.opsForValue().get(cacheKey); if (json != null) { return JSON.parseObject(json, UserVO.class); } User user = userMapper.selectById(userId); if (user == null) { // 布隆过滤器已经挡掉了绝大多数不存在的key, // 这里仍然保留空值缓存,是为了兜住“合法但暂时无数据”的key redisTemplate.opsForValue() .set(USER_NULL_CACHE_KEY + userId, "EMPTY", NULL_TTL_SECONDS, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue() .set(cacheKey, JSON.toJSONString(user), DATA_TTL_SECONDS, TimeUnit.SECONDS); return convert(user); }这段代码的实现重点在于:布隆过滤器和空值缓存不是二选一,而是叠加。布隆过滤器拦掉随机攻击流量,空值缓存兜住“合法 ID 但暂时没有数据”的场景。
5.3 批量预热与全量构建
布隆过滤器需要提前“记住”业务里所有合法 key,否则真实存在的用户也会被误拦。这个构建过程是一个典型的批量任务,不能靠手动一条条添加。
@Service public class BloomFilterWarmUpService { private static final String BLOOM_KEY = "user:bloom"; @Autowired private UserMapper userMapper; /** * 全量重建布隆过滤器,建议低峰期执行 */ public void warmUp() { try (Jedis jedis = new Jedis(host, port)) { int pageSize = 1000; long lastId = 0; while (true) { List<Long> ids = userMapper.selectIdsAfter(lastId, pageSize); if (ids == null || ids.isEmpty()) { break; } // 使用 pipeline 批量提交,避免逐条网络往返 Pipeline pipeline = jedis.pipelined(); for (Long id : ids) { pipeline.bfAdd(BLOOM_KEY, id.toString()); } pipeline.sync(); lastId = ids.get(ids.size() - 1); } } } }实际生产环境,建议定时全量重建,而不是增量添加。原因很简单:标准布隆过滤器不支持删除,如果系统里存在大量逻辑删除或失效数据,过滤器里的“已存在”标记会越积越多,误判率会被放大。全量重建虽然成本高,但结构干净。
批量预热要考虑分页性能。selectIdsAfter这种基于主键游标的分页方式,比传统的 limit offset 更快,适合大数据量场景。如果表数据在千万级别,建议把全量重建放到离线任务里执行。
5.4 容量与误判率设计
容量和误判率直接决定内存占用。布隆过滤器的内存估算可以用近似公式:
内存大小(bit) ≈ 1.44 * n * log2(1/p)其中 n 是预估元素数量,p 是期望误判率。比如要放 100 万个元素,误判率 1%,需要的 bit 数大约是:
1.44 * 1000000 * log2(100) ≈ 1.44 * 1000000 * 6.64 ≈ 956 万 bit ≈ 1.14 MB所以布隆过滤器在内存上的优势非常明显,一百万个元素只要 1MB 左右。这个数字可以让你放心把容量预留得大一些,因为内存成本很低。
真正要小心的不是内存,而是“误判会把真实存在的数据拦掉”这件事。注意,布隆过滤器的误判方向是“可能存在但实际不存在”,它不会把真实存在的 key 误判成不存在。所以用户数据的正确性不受影响,最多是多放行一些不存在的请求到缓存层,和缓存空值方案形成嵌套兜底。
6. 互斥锁:解决单 key 并发击穿
布隆过滤器能挡掉“不存在”的 key,但挡不住“存在但缓存刚好过期”的热点 key。热门数据在缓存过期的瞬间,如果同时来几百个请求,它们全部查缓存未命中,然后一起打数据库。这个问题叫击穿,和穿透是两回事,但经常一起出现。互斥锁就是用来解决这个问题的。
6.1 加锁回填
核心思路是:缓存未命中时,先尝试获取一个分布式锁。只有拿到锁的线程才能查数据库并回填缓存,其他线程则短暂等待后重试。
public UserVO getUserByIdWithLock(Long userId) { String cacheKey = USER_CACHE_KEY + userId; String json = redisTemplate.opsForValue().get(cacheKey); if (json != null) { return JSON.parseObject(json, UserVO.class); } String lockKey = "lock:" + cacheKey; String lockValue = UUID.randomUUID().toString(); // 使用 set nx ex 原子加锁 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (!locked) { try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 没拿到锁,递归重试一次 return getUserByIdWithLock(userId); } try { // 双检:拿到锁后再看一次缓存,防止重复回填 json = redisTemplate.opsForValue().get(cacheKey); if (json != null) { return JSON.parseObject(json, UserVO.class); } User user = userMapper.selectById(userId); if (user == null) { redisTemplate.opsForValue() .set(USER_NULL_CACHE_KEY + userId, "EMPTY", NULL_TTL_SECONDS, TimeUnit.SECONDS); return null; } redisTemplate.opsForValue() .set(cacheKey, JSON.toJSONString(user), DATA_TTL_SECONDS, TimeUnit.SECONDS); return convert(user); } finally { // 释放锁时必须校验是否为当前线程持有的锁 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; redisTemplate.execute( new DefaultRedisScript<>(luaScript, Long.class), Collections.singletonList(lockKey), lockValue); } }这里有几个实现细节必须注意。
锁的 value 用 UUID,释放锁时用 Lua 脚本先校验再删除。这是为了防止一个线程的锁过期后,另一个线程拿到了同名的锁,第一个线程却把第二个线程的锁删掉。
锁的过期时间要大于回填缓存的总耗时,否则业务还没查完数据库,锁就自动释放了,其他线程趁虚而入。
递归重试会造成深层次的栈调用,生产环境建议改成 while 循环加最大重试次数,防止极端情况下无限递归。
6.2 锁的释放与线程安全
上面代码里的finally块和 Lua 脚本是必须的,不能偷懒直接调用delete(lockKey)。直接删除的问题在于:锁到期后,线程 A 还没执行完,线程 B 拿到锁开始执行,此时线程 A 结束并删除了 lockKey,相当于把线程 B 的锁误删了,线程 C 又拿到锁,形成连锁问题。
每次加锁设置 10 秒过期,是一个通用配置。如果你的回填逻辑里包含复杂查询或远程调用,建议单独评估,把过期时间调大到 30 秒甚至更长,或者使用 Redisson 这类带看门狗机制的客户端,自动续期。
6.3 补充限流兜底
即使加了互斥锁,数据库在缓存空窗期仍然要承受一次查询。恶意流量如果绕过合法 ID 检测,大量请求同一批 ID 区间,互斥锁只能保证“每个 key 只放一个线程进去”,但不同 key 的请求仍然可以直接穿透。这种情况下需要在接口入口加一层限流。
@Component public class RateLimitInterceptor implements HandlerInterceptor { private final RateLimiter limiter = RateLimiter.create(500); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!limiter.tryAcquire()) { response.setStatus(429); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":429,\"msg\":\"too many requests\"}"); return false; } return true; } }限流可以放在应用层,也可以放在网关层。生产环境更推荐使用 Sentinel 或网关限流组件,通过配置动态调整阈值,不需要改动业务代码。这里给的是 Guava RateLimiter 的简化演示,说明拦截器接入方式。
7. 组合架构:缓存穿透治理的完整链路
7.1 分层职责
单个方案都有短板,生产环境普遍采用分层组合:
| 层级 | 组件 | 核心职责 |
|---|---|---|
| L1 接入层 | 参数校验、限流 | 过滤非法请求、控制峰值流量 |
| L2 拦截层 | 布隆过滤器 | 挡掉一定不存在的 key |
| L3 缓存层 | Redis 缓存 + 空值缓存 | 承接合法请求的读流量 |
| L4 回源层 | 互斥锁 | 防止单个热点 key 并发打爆数据库 |
| L5 数据库 | 连接池监控、慢查询告警 | 识别问题、快速止损 |
实际请求链路:
呼叫方发出的请求先经过参数校验,非法的直接拒绝;随后过限流,超阈值的直接降级返回;接着查布隆过滤器,不存在的 key 立即返回空结果;剩下的请求走 Redis 缓存,命中的直接返回;最后缓存未命中时,由互斥锁保证只有一个线程查数据库并回填,其他线程等待后复用缓存。
7.2 建议的演进顺序
不建议一次性把所有组件都铺上去,改动量大会增加回归风险。建议按以下顺序逐步演进:
- 先上线参数校验和限流,成本最低,见效最快;
- 再接入布隆过滤器,构建批量预热任务;
- 然后为热点 key 增加互斥锁逻辑;
- 最后把缓存空值 TTL、布隆过滤器容量、限流阈值全部参数化,方便动态调整。
每一层上线后,都要对比数据库 QPS、Redis 内存、接口 RT 三项核心指标,确认确实有收益,再进入下一步。
8. 性能观察与压测方法
8.1 需要观察的指标
治理缓存穿透,到底有没有效果,不能靠感觉。至少要观察四组指标:
| 指标 | 观测位置 | 说明 |
|---|---|---|
| DB QPS | 数据库监控 | 下降明显,说明穿透请求被拦住了 |
| Redis 内存 | Redis INFO | 空值 key 数量是否在可控范围 |
| 接口 RT | 应用监控 | P99 是否回落 |
| 缓存命中率 | Redis INFO | 命中率低说明大部分请求被布隆过滤器拦掉了 |
8.2 压测步骤
压测要在测试环境独立进行,避免影响线上业务。步骤如下:
- 准备一批固定存在的 ID 和一批随机不存在的 ID;
- 使用 wrk 或 JMeter 分别对两类 ID 发起并发请求;
- 先测“无防护”的原始逻辑;
- 依次开启缓存空值、布隆过滤器、互斥锁;
- 每开启一层,记录数据库 QPS、Redis 内存、接口响应时间。
压测脚本示例:
# 对不存在的 userId 做并发压测 wrk -t8 -c200 -d60s \ -s /path/to/random_user.lua \ "http://127.0.0.1:8080/api/user/detail?id=99999999"压测时注意:不要在业务高峰期进行;连接数要控制在数据库连接池阈值内;测试完成后要清理产生的空值缓存和压测数据。
8.3 布隆过滤器内存估算
布隆过滤器的内存大小由容量和误判率决定,估算公式为:
内存(bit) ≈ 1.44 * 预估元素数量 * log2(1 / 误判率)假设业务用户量 1000 万,误判率设置 1%,内存需求大约在 11MB 左右。这个量级对 Redis 来说几乎可以忽略不计。所以容量预留可以大胆一些,避免业务增长后频繁重建过滤器。误判率则不建议设置太低,0.1% 到 1% 之间是比较合理的范围,再低性价比不高。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 真实用户被布隆过滤器拦截,返回空数据 | 过滤器构建时未覆盖全量数据 | 抽查已存在 ID,执行 BF.EXISTS 判定 | 全量重建过滤器;新注册用户通过 BF.ADD 补充进过滤器 |
| 空值缓存导致数据延迟可见 | 空值 TTL 设置过长 | 查看空值 key 的剩余有效时间 | 按业务可接受延迟调短 TTL,或改用布隆过滤器主导拦截 |
| Redis 内存出现大量无效 key | 攻击者持续构造不存在的 key | 执行keys user:null:*统计数量 | 调整空值 TTL;上线布隆过滤器前置拦截 |
| 互斥锁死锁,请求大量超时 | 锁释放失败,或递归重试过深 | 查看 Redis 中 lock 前缀 key 是否残留 | 用 Lua 脚本释放锁;把递归改成 while 重试并设置最大次数 |
| 布隆过滤器误判率升高 | 数据删除较多,过滤器无法删除 | 对比存量数据与过滤器标记差异 | 周期性全量重建过滤器 |
| 压测时数据库 QPS 仍然很高 | 压测的 ID 区间也被布隆过滤器判定为存在 | 使用随机不存在的 ID 验证拦截效果 | 确认容量和误判率配置是否合理 |
| 接口限流误伤正常用户 | 阈值设置过低 | 查看限流日志与正常 QPS 对比 | 用网关配置动态调整阈值 |
排查时,先看日志里“缓存未命中”“数据库已查”“空值已回填”三个关键节点,能快速定位请求是卡在哪一层。再配合 Redis 的慢查询日志和数据库慢查询日志,穿透问题大多能在十分钟内定位。
10. 最佳实践与使用建议
缓存穿透治理不是一次性改造,而是一个持续演进的过程。下面这些工程实践建议可以直接套用到现有项目:
- 缓存空值只作为兜底,不作为主防线。设置独立前缀和短 TTL,避免与真实数据互相污染。
- 布隆过滤器采用全量定时重建 + 新数据实时添加的组合策略。标准布隆过滤器不支持删除,数据变更频繁时,全量重建是保证准确性的最直接手段。
- 互斥锁和缓存回填要保证原子性。加锁、查库、回填、释放锁全链路都要考虑异常情况,尤其要处理好锁的续期和误删。
- 所有缓存 key 设计统一前缀,例如
业务:模块:类型:ID。排查问题和统计数据时一目了然。 - 压测前先明确数据库连接池上限和 Redis 内存限制,避免测试过程本身造成故障。
- 涉及用户 ID、手机号、订单号的请求日志,必须做脱敏处理,不允许打印完整业务数据。
- 每层防护组件都要有开关配置。生产环境出现异常时,能第一时间回退到上一版逻辑,而不是紧急改代码发布。
看回到标题这句话,缓存空值确实是初学者最常用的招,它不丢人,只是一个起点。真正工程化的做法,是用布隆过滤器压住大规模无效 key,用互斥锁保护热点 key 的回源,用限流守住系统整体的安全水位。每一步都不复杂,但组合起来,效果会非常明显。如果你正准备优化缓存链路,建议先在测试环境跑通布隆过滤器的批量预热,再叠加互斥锁压测一次,数据库的查询压力变化会让你对“穿透治理”有一个更直观的判断。