做后端开发这几年,我发现一个很有意思的现象:很多团队在缓存设计上,一上来就搞Redis Cluster、搞MQ异步更新、搞Canal监听Binlog,架构图画得漂漂亮亮,结果线上跑了不到一个月,各种数据不一致、缓存雪崩、维护成本爆炸的问题全冒出来了。
其实很多业务场景根本不需要那么复杂。就拿我今天要聊的这套“懒更新|单点查询”方案来说,它在应对那些“读多写少、单条数据独立查询、实时性要求没那么变态”的业务时,简直是降维打击。这套方案的精髓就八个字:用的时候才更新,查一条就更新一条。它不是什么高深莫测的新技术,而是一种对缓存生命周期管理的极致简化。
这篇文章,我想把我在实际项目中落地这套方案的完整思路、核心代码、参数调优过程以及踩过的坑,一次性分享出来。如果你正在为某个列表页或者详情页的缓存更新头疼,或者你的团队正在为“缓存更新策略”扯皮,这篇文章应该能给你提供一个非常务实的解题思路。
1. 内容整体设计与思路拆解
1.1 先搞清楚“懒更新”到底在解决什么问题
很多朋友一听“懒更新”,下意识就会想到Lazy Loading(懒加载),觉得这不就是“用的时候再查数据库嘛”。这么理解也没错,但不全面。在缓存场景下,“懒更新”其实是一套组合拳,它包含三层意思:
- 读时更新(Refresh-on-Read):当请求到来时,如果发现缓存不存在或者已经过期,才去数据源加载最新数据并回填缓存,而不是在数据变更时就主动推送到缓存。
- 写时不更新(No-Write-Through):数据发生增删改时,我们不去管缓存,最多只是把旧的缓存标记为失效或直接删除,让下一次读请求去触发重建。
- 单点隔离(Single-Item Isolation):我们只对“被查询的那一条数据”做懒更新,不涉及整个列表的批量刷新,做到谁被读、谁就新鲜。
说白了,这是一种典型的Cache-Aside + 主动失效的混合模式。我见过太多团队把缓存更新做得过于“勤快”:业务代码里,每次update完数据库,紧接着就去update缓存,还得小心处理并发更新顺序,生怕两个线程把数据写串了。这种“勤更新”模式在单体应用、低并发下没问题,但一旦涉及分布式、涉及MQ异步通知,缓存和数据库的一致性就像走钢丝。
而懒更新最聪明的地方在于,它彻底放弃了“让缓存跟着数据源实时变”这个执念。它的核心假设是:大多数数据在大多数时间内,其实是没有被读取的。既然没人读,我为什么要费劲去维护它的新鲜度?不如等到有人读了,我再花一次IO去把它变成最新的,然后把这份“新鲜”缓存下来,供接下来一段时间内的读请求共享。
为了更直观地理解,我们可以把缓存想象成一个公共图书馆的分馆仓库。勤更新模式是:每当有新书入库(数据库更新),馆员立刻骑着车把书送到每个分馆(更新所有缓存),哪怕这个分馆一年都没人来借书。而懒更新模式是:平时分馆仓库就空着,直到有人来借某本书(单点查询),馆员才去总库把书调过来(加载并回填缓存),并在分馆里放一段时间(设置过期时间),等人来借。
1.2 为什么“单点查询”是懒更新的最佳拍档
理解懒更新的价值还不够,我们得把它放到“单点查询”这个具体的业务场景里看。
我做过的电商后台、内容管理系统,最典型的一个需求就是通过主键ID查询详情。这种查询的规律非常固定:QPS高、字段多、单次查询开销大,但数据之间彼此独立。比如商品详情页,你查ID为1001的商品,和查ID为1002的商品,完全是两条独立的缓存链路。
如果我们用“懒更新 + 单点查询”的思路来做,效果立竿见影:
- 每一个商品ID都是一个独立的缓存Key,比如
product:1001。 - 只有用户真正访问了1001这个商品,系统才会去数据库查一遍1001并写缓存;没人访问,1001在缓存里就不存在,数据库也不会多承担一次无谓的查询。
- 当后台修改了1001商品的价格,我们要做的仅仅是删除
product:1001这个Key。下一次用户再来访问,缓存没了,系统自动触发懒更新,读取到的就是最新的价格。
这样做的好处至少有三个:第一,IO成本极低,缓存里存的全是被真实访问过的热点数据,不存在“缓存了一大堆没人看的冷数据”的内存浪费;第二,更新逻辑极简,更新数据时只删除一个Key,根本不用关心缓存里现在是什么,会不会并发写坏;第三,避免缓存雪崩,因为每个Key的加载是被各自的流量打进来的,天然分散,不会你更新一个列表就导致所有缓存Key同时失效。
2. 核心细节解析与实操要点
2.1 缓存Key的设计:这是地基中的地基
很多新手在纠结用什么缓存中间件、怎么解决穿透之前,其实最先应该想清楚的是Key的设计。在懒更新模式下,Key的设计直接决定了“单点查询”的粒度,也决定了后续失效的准确性。
我总结下来,一个好的单点缓存Key通常长这样:
业务域:实体类型:主键ID:维度的哈希值举个例子,一个商城的商品服务,我可能会设计出以下几种Key:
mall:product:1001:base # 商品基本信息 mall:product:1001:price # 商品价格信息 mall:product:1001:inventory # 商品库存信息 mall:sku:2001:stock # SKU维度库存注意,我把不同的业务属性拆成了不同的Key,而不是把所有信息都塞进一个Key里。为什么要这么做?因为懒更新的触发可以有层次感嘛。比如用户看商品列表页,只需要用到base和price;只有当用户点进详情页,才会去查inventory。这样当库存发生变动时,我只需要把inventory这个Key删掉,base和price还能继续被缓存命中,不会因为一个次要字段的修改导致整个商品详情缓存失效,白白增加一次数据库查询。
另外我要啰嗦一句:Key里千万不要拼上时间戳或者随机数。有些朋友为了“强制刷新”,喜欢在Key后面加上:v2、:v3这种版本号,或者拼上当前小时的时间戳。这在懒更新里是致命的,因为懒更新的前提是“请求可以复用之前的缓存”。如果Key每过一个小时就变一个,那缓存完全就形同虚设了,每个新Key都得重新查库。
2.2 TTL(过期时间)的科学设定:不是越长越好,也不是越短越好
确定了Key之后,紧接着就要面对一个灵魂拷问:缓存到底设多久过期?
在懒更新模式下,TTL是一个非常微妙的参数。设得太长,数据新鲜度差,后台改了数据,用户最长可能要等那么久才能看到更新;设得太短,缓存频繁失效,懒更新就会退化成“每次请求都查库”,失去了减少数据库压力的意义。
我通常在项目里将TTL配置为动态的,即:
- 核心稳定型数据(如商品标题、图片):TTL可以设置到1小时或者更长。比如12小时,因为这些数据变化频率极低。
- 中等变动型数据(如商品价格、活动标签):TTL设置大致为5到10分钟。即使后台改了,最迟几分钟内全网也能刷出来。
- 高频变动型数据(如库存):TTL设置成30秒,或者干脆不设TTL,而是依赖写操作后的手动删除。
我知道有些朋友可能会担心:TTL过期前的一瞬间,如果大量请求同时打到一个Key上,大家都发现缓存过期了,然后一起打到数据库,导致数据库压力飙升——这就是经典的缓存击穿问题。
这个担心非常对,所以我在用懒更新方案时,永远会配合一把“互斥锁”。当某个Key发生缓存失效时,只有一个线程能拿到锁去查库,其它线程会短暂地自旋等待或直接返回旧值(利用逻辑过期时间),拿到锁的线程更新完缓存后,其余线程再从缓存读取。代码层面,在Java里可以这样处理:
public Product getProductById(Long id) { String cacheKey = "mall:product:" + id + ":base"; String json = redis.get(cacheKey); if (json != null) { return JSON.parseObject(json, Product.class); } // 尝试获取分布式锁,防止缓存击穿 String lockKey = "lock:product:" + id; boolean locked = redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (!locked) { // 没拿到锁,可能是别的线程正在重建缓存,睡一会儿再查一次 Thread.sleep(50); json = redis.get(cacheKey); if (json != null) { return JSON.parseObject(json, Product.class); } } try { // 业务查询逻辑,这里是真正查数据库的地方 Product product = productDao.selectById(id); if (product != null) { redis.setex(cacheKey, 3600, JSON.toJSONString(product)); } return product; } finally { if (locked) { redis.delete(lockKey); } } }你可能注意到了,我设TTL时用了3600秒(一小时),但我在前面提到核心稳定数据可以设12小时。这里的取舍思路其实很简单:TTL定得越长,数据库压力越小,但数据新鲜度越差;TTL定得越短,新鲜度越好,但数据库压力越大。你需要在业务容忍度和技术成本之间找一个平衡点。我习惯先设置一个保守值(如1小时),然后通过压测和线上监控去动态调整,后续我也会专门写一段讲这个调优过程。
2.3 主动失效的边界:更新数据后如何删Key
刚才说了懒更新是“读时更新”,那“写时”到底要不要碰缓存?我的结论是:写的时候,轻轻碰一下,把旧缓存删掉就行,千万别去写新缓存。这就叫主动失效(Invalidate)。
很多同学更新完数据库后,喜欢顺手把新数据直接set到缓存里,这其实是个坏习惯。它至少会带来两个问题:
- 并发覆盖问题:如果线程A更新数据库把价格改成了100,线程B更新数据库把价格改成了80,然后A先set缓存(写入100),B后set缓存(写入80)。但数据库里的顺序可能是A先写、B后写,最终价格是80,缓存跟着B写80,如果A的写晚于B,那就出现A覆盖B的情况。在极端并发下,缓存和数据库的最终值很容易错位。
- 冗余序列化开销:更新操作往往伴随着许多业务侧的逻辑(如校验、发消息、生成日志),如果还要把对象手动塞进缓存,相当于把写链路和缓存强耦合了。
正确的做法,也是业界最经典的模式,就是“更新数据库 + 删除缓存”。删掉旧Key以后,按懒更新的逻辑,下一次请求自然会把最新的查出来并放进去。这里还有个细节要注意:尽量先更新数据库,再删缓存;不要先删缓存再更新数据库。如果先删缓存,刚好来一个请求,发现缓存空了,去查库,结果查到的是还没被更新完的旧数据,又把旧数据写回了缓存,等数据库真正更新完,缓存里却已经是脏数据了。
2.4 数据补偿机制:给懒更新上保险
懒更新模式最大的隐患是什么?不是缓存击穿,也不是并发覆盖,而是数据库更新成功,但缓存删除失败。
大家想一下:我们写好一条SQL通过事务提交给数据库,数据库返回成功;接着去Redis执行DEL,这时候如果由于网络抖动、Redis连接池异常、或者Redis宕机,导致DEL命令没发出去。那结果就是:数据库里是最新的值,缓存里还是旧的值,这个脏状态要一直持续到缓存TTL过期,你才能被懒更新给纠正过来。
怎么办?生产环境里,我强烈的建议必须给这套流程加上一条数据补偿机制。方案有很多,我推荐一种简单实用的:延迟双删。
具体做法是,在“更新数据库”后,“删除缓存”;但如果怕删除失败,我们可以在几百毫秒后再执行一次删除。为什么这么做?因为在极端情况下,可能有一个在读请求刚好在缓存失效的间隙,查到了数据库的旧数据,并把它回填到了缓存。延迟双删就是留出这个间隙,让那些可能被写回的错误旧缓存再次被清掉。代码逻辑示意:
public void updateProduct(Product product) { // 1. 先更新数据库 productDao.updateById(product); // 2. 立即删除缓存 redis.delete("mall:product:" + product.getId() + ":base"); // 3. 延迟再删除一次,补偿可能发生的旧数据回填 String key = "mall:product:" + product.getId() + ":base"; expireAfterDelay(key, 500, TimeUnit.MILLISECONDS); }我实际开发中,会把延迟双删做成一个独立的MQ消息发送,确保每一次“更新数据库”的事件都对应一个“延迟删除缓存”的消息。消费者拿到消息后,做幂等删除。这样即使某个节点的进程崩溃,消息队列里依然有补偿任务,不会让垃圾数据在缓存里躺太久。请注意,这里所谓“延迟”并不是为了异步,而是为了应对缓存回填的并发窗口。
3. 实操过程与核心环节实现
3.1 整体架构:到底需要哪些组件?
纸上谈兵了这么久,我们来点真的。这套“懒更新|单点查询”方案,在架构上的依赖其实非常少,非常适合中小团队快速落地。你只需要准备:
- MySQL数据库:作为持久化数据源。
- Redis:作为二级缓存,存储已经序列化后的商品/文章/用户数据。
- Redis分布式锁(Redisson或自研):保证单Key重建时只有一个线程查库。
- MQ消息队列(可选):用于做延迟双删和更新日志异步化,如果系统规模很小,也可以直接用定时任务替代。
聊一下为什么选Redis而不是本地内存。我知道有些极端性能追求者会把缓存放进JVM堆内(Caffeine),那确实快,可以到零网络开销。但一旦涉及分布式多节点部署,本地缓存的一致性就特别头疼。节点A更新了缓存,节点B不知道,用户又一次请求被负载均衡到B上,读到的还是旧数据。用Redis这种集中式缓存,天然就是所有节点共享的,配合懒更新和主动失效,一致性模型非常清晰。所以我的建议是,如果项目是多实例部署,优先用集中式缓存;如果是单体单机,本地缓存 + 懒更新也可行,但复杂度一点没少,收益却有限。
3.2 核心查询链路:一步一步走通
下面我给大家一个非常具体的、从请求进来到返回出去的完整链路,大家可以对照自己的业务来实现。假设我们查询的是product/1001。
- 步骤一:接收请求
GET /api/product/1001。 - 步骤二:组装缓存Key
mall:product:1001:base,去Redis执行GET。 - 步骤三:如果Redis返回非空,直接反序列化并返回给前端,不查任何数据源,流程结束。
- 步骤四:如果Redis返回空,进入“懒更新”流程:
- 尝试获取分布式锁
lock:product:1001(这里要设置一个合适的锁超时时间)。 - 获取成功,说明当前线程是第一个发现缓存失效的线程,可以查库;
- 获取失败,说明已经有别的线程正在重建缓存,当前线程可以短暂自旋等待(通常50ms~200ms),然后再次尝试
GET,如果依然没有数据,再走一次锁获取逻辑,直到拿到锁或者超时。
- 尝试获取分布式锁
- 步骤五:拿到锁的线程,执行
SELECT * FROM product WHERE id = 1001,注意过滤掉逻辑删除的标记。 - 步骤六:把查询结果序列化为JSON字符串,执行
SETEX mall:product:1001:base 3600 '{json}'。 - 步骤七:释放锁。
- 步骤八:返回数据给前端。
这条链路每一步都有坑。比如第四步的“自旋等待”,如果等待时间设置得比锁超时时间还长,那可能锁早被释放了,但业务请求还在傻傻地等,白白增加了响应时间。所以这里我习惯把自旋总时长控制在锁超时时间的1/3以内。再比如第六步,我为什么建议用SETEX而不是先SET再EXPIRE?因为后者是两步操作,如果SET成功但EXPIRE失败,这个Key就会变成永不过期的脏缓存,这直接违反了懒更新“有降水期限”的假设。
3.3 参数选择:锁超时怎么定,自旋多少次合适?
很多同行问我,懒更新方案里,最难调的就是那两个时间:锁超时时间和自旋等待时间。
- 锁超时时间(Lock Lease Time):正常情况下,查一次库加上写一次Redis,耗时大概在10~50ms之间。考虑到可能有大事务、慢SQL、网络抖动,我一般把锁超时时间设为3秒。太长的话,万一持有锁的线程挂了,其它请求会阻塞在拿锁上;太短的话,查询还没完成,锁就被自动释放了,其它请求会重复涌入数据库。
- 自旋次数:我的经验是不要把自旋当成一种无限等待。通常最多自旋3次,每次间隔50ms;如果3次之后还没有拿到锁或者还没看到数据,果断放弃,直接降级为查库(但要注意加一个短时间的本地限流,避免降级流量打到数据库)。这样既保证了绝大多数情况下只让一个线程查库,又不会因为极端竞争而拖死请求。
这里补一个常见场景:如果查询的商品在数据库里压根不存在(比如ID被恶意传了-1),我们该怎么办?如果按照常规懒更新逻辑,缓存没有,查库没有,我们就不写缓存,那下次请求还得穿透一次到数据库。这是攻击者的福音:一个不存在的ID反复查询,就能把数据库打死。这就是缓存穿透。我的解决办法很简单:对查不到的数据,也在缓存里存一个短的占位标志,比如EMPTY,TTL设置为2分钟。这样同一ID的请求在2分钟内就不会再打到数据库了,等TTL过了自然允许从数据库重新查一次,以免后台在这个期间新增了一条合法数据而缓存永远挡住。
if (product == null) { // 防止缓存穿透:缓存空标记,时间短一点 redis.setex(cacheKey, 120, JSON.toJSONString(Optional.empty())); return null; }3.4 Redis上的原子操作:防止并发写坏数据
在实现“写后删缓存”这个动作时,我强烈建议不要用先GET再DEL这种非原子的两步操作。你应该直接DEL,它本身就是原子的。那是不是就意味着万无一失了呢?也不全是。
考虑一个场景:线程A在做懒更新,查到了数据库的旧值(因为数据库的事务还没提交),正准备写缓存;线程B完成了数据库更新,然后发了删除缓存指令;但删除指令先执行了,A随后把旧值写进了缓存。结果就是:数据库已经是最新的,缓存却被旧值覆盖,而且这个旧值要等到TTL到期才能被懒更新纠正。这正是我前面提到的“延迟双删”的经典适用场景。双删就是为了兜住这种“旧值晚到”的极端情况。
当然,我们也可以用更优雅的方案:利用Redis的Lua脚本,在写入前校验一个版本号,版本号不对就不让写。但实话实说,在大多数业务里,延迟双删已经足够用了。引入版本号会增加业务侵入性,反而违背了懒更新“轻量、简单”的初衷。
3.5 冷启动与预热:懒更新在系统刚上线时怎么办?
懒更新有一类被吐槽最多的情况就是冷启动。比如系统刚上线,或者Redis重启后,缓存里是空的。这时候如果大批用户同时涌进来,面对一堆空缓存,懒更新机制会退化成“每个请求都查库”。
这个问题客观存在,但处理起来也不难。在系统上线前或Redis刚建立时,我们会从数据库里筛选出一批热点数据,提前写入缓存,这个过程叫缓存预热。但是预热的数量一定要克制,只预热业务上明确的热点,比如销量Top1000的商品,而不是全表预热,否则又回到了“缓存了一堆冷数据”的浪费模式。
另外,如果你给Redis配置了持久化(RDB或AOF),重启后大部分缓存还能恢复,冷启动的问题会被大大缓解。如果你的项目对实时性要求很低,甚至可以考虑做个简单守护线程,每过一段时间检查一下某些核心Key是否存在,不存在就主动触发一次懒更新(其实就是提前查一次库)。
4. 常见问题与排查技巧实录
这套方案我前前后后在不同项目里用过三五次了,其中的酸甜苦辣最有发言权。下面我把那些最常遇到的“疑难杂症”整理成了一份速查表,都是真心话,希望能帮你少走点弯路。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 刚改完数据库,前端死活看不到最新值 | 缓存删除失败,旧缓存未过期 | 检查Redis连接池是否被打满;启用延迟双删或MQ补偿机制 |
| 缓存明明存在,但数据库压力依然很大 | 可能逻辑过期了,大量请求在未命中后全部打到库 | 加分布式锁防击穿,同时考虑适当延长TTL |
| 更新完数据库后,缓存时而对、时而错 | 删除缓存与更新数据库的顺序反了,或并发写缓存 | 保证“先更新数据库,再删除缓存”,严格禁止更新后直接写缓存 |
| 一个不存在的ID反复查询,数据库QPS飙升 | 缓存穿透,空数据没有缓存 | 设置空值缓存标记(TTL 2分钟左右) |
| Redis内存持续增长,全是无用Key | Key设计不合理,或者写后过期时间未设置 | 建议所有缓存都带TTL,定时巡检大Key和无用Key |
| 两个线程同时回填了同一份缓存,数据一致,但浪费了一次查库 | 锁竞争导致的重复查询 | 优化锁的获取逻辑,或使用Redisson自带锁+看门狗机制 |
4.1 排查“缓存与数据库不一致”的完整思路
如果你线上遇到数据不一致,别慌,按下面的顺序排查,一般都能快速定位。
- 第一步,确认数据库的值。先直接查库,看数据库里到底是多少。如果数据库本身值就是错的,那问题不在缓存方案,而在业务写入逻辑。
- 第二步,确认缓存的值。用
GET mall:product:1001:base查一下Redis当前存的是什么。对比一下两者,如果不一致,进入下一步。 - 第三步,确认是谁把旧值写进缓存的。这就考验你有没有给缓存Key留痕了。我的建议是,在缓存value里埋一个
lastUpdateTime字段。如果你的业务结构复杂,可以单独开一个Key来记录缓存的更新时间,这样我们一查,就能看出来这个缓存是哪个时间点回填的,进而推测回填时数据库是否已经更新。 - 第四步,检查删除动作。看看Redis的慢查询日志,确认
DEL命令是否在数据库事务执行之后发出,有没有超时或者失败。 - 第五步,看看有没有旧代码。很多线上事故其实是发布了新代码,但某个服务节点还在跑老版本逻辑,也就是没有走“延迟双删”逻辑。一般你排查到这里,就会恍然大悟了。
4.2 关于“缓存击穿”的亲身经历
在这里分享一个我记忆尤深的夜晚。有一次电商平台做秒杀活动,零点开始,商品详情页被疯狂刷新。我们当时用的就是懒更新方案,TTL设置的1小时。零点一过,一大批精准流量打到一个爆款商品详情页,结果那个商品的缓存Key正好在23:59分过期了。一瞬间,几百个线程同时发现缓存没命中,同时发起数据库查询,直接把主库的CPU打到了100%,接口大面积超时。
那次事故之后,我把“互斥锁”的代码严格加上了,同时给所有热点详情数据设置了一个“逻辑过期时间”。即便缓存Key在物理上还在,但逻辑上认定为过期,我们也可以只放一个线程去更新,其它线程照常返回旧的数据。这样做虽然在某些极端情况下会有短暂数据延迟,但至少能保证系统不被击垮。做缓存方案,第一原则永远是保命,其次才是保新鲜。
4.3 几个容易被忽略的日常巡检项
除了上述这些关键时刻的排查,日常巡检也很重要。我每次在项目上线后,都会在监控系统中配置这么几个指标:
- 缓存命中率:如果命中率长期低于80%,说明懒更新的配置可能有问题,比如TTL太短、Key设计得太细(导致选择性太差),或者预热策略失效。
- Redis慢查询:监控
SETEX、DEL、GET的耗时。如果出现大量慢查询,要看是不是大Key问题,或者Redis实例CPU是否饱和。 - 数据库慢SQL:如果懒更新机制退化成“每次查库”,最直接的表现就是数据库慢SQL增多。我在夜间的监控大屏上,最不愿意看到的就是平滑的数据库QPS曲线突然变成锯齿状,那基本就是缓存失效潮来了。
写在最后的个人体会
我在很多项目里尝试过“踊跃更新强一致”的激进方案,也试过“全量缓存无脑过期”的粗暴方案,绕了一大圈之后发现,“懒更新|单点查询”这种看上去土土的、不加任何花哨架构的思路,反而是日常业务中最稳定、最可维护的组合。它不会给你带来数据实时性99.99%的爽感,但它能让你半夜少接几个报警电话,让你在排查问题时不必绞尽脑汁去推断各种并发交错。
这里还想分享一个小技巧,是我在Java/Go项目中反复使用的:把懒更新逻辑用AOP或注解抽象成一个公共组件,叫@CachedSingle。业务方只需要在方法上标注缓存Key的SpEL表达式,以及TTL时间,剩下的“查缓存、加锁、回填、防击穿”全部由切面统一完成。这样一来,业务代码里就不会散落着各种Redis操作的细节,既统一了规范,又方便后续做全局性的策略调整(比如统一把TTL从30分钟改成1小时,或者统一接入延迟双删的MQ)。这个思路和Spring Cache很像,但Spring Cache自带的那套CacheManager太笨重了,我更倾向于自己写一个小巧的封装,十几行代码就能搞定,后续扩展也方便。
希望这篇文章能改变你对手写缓存策略的看法。复杂的架构确实令人向往,但能用最短的代码服务好业务,让系统的风险面降到最低,才是真正值得长期坚持的设计哲学。大家如果在自己项目中落了这套方案,欢迎多交流踩坑经验,我们一起打磨这套“懒”的智慧。