新闻详情

新闻详情

首页 / 资讯中心 / 详情

Redisson分布式锁实战:原理、最佳实践与常见坑

发布时间:2026/9/26 18:35:31来源:尧图网络
Redisson分布式锁实战:原理、最佳实践与常见坑
1. 从一把简单的锁说起为什么单机锁救不了分布式场景1.1 单机锁的边界先说个最常见的场景。你在一个电商系统里写库存扣减代码大概是这样的synchronized (this) { int stock getStock(productId); if (stock 0) { return 已售罄; } setStock(productId, stock - 1); }单机部署的时候这段代码一点问题没有synchronized 保证同一时刻只有一个线程能进入临界区。但系统一旦上了多节点比如搞了两台 Tomcat 做负载均衡这个锁就立刻失效了。原因很简单两个 JVM 是独立的进程各自持有一把 synchronized 锁A 节点上的线程拿到了锁B 节点上的线程根本不知道照样可以进临界区。这就像两栋楼各有一把门锁你在 1 号楼锁了门2 号楼的人进出完全不受影响。用户请求被负载均衡到不同节点时库存就被多扣了。这就是分布式锁存在的根本原因——多个进程之间的互斥控制单机的锁机制根本管不到进程边界之外。1.2 分布式锁的使用场景拆解很多人一听到分布式锁第一反应是这不就是给 Redis 加个锁嘛。实际上它的使用场景非常具体基本集中在三类问题第一类是资源互斥。比如库存扣减、账户扣款、订单号生成这类操作的特点是对同一个数据做并发写必须保证同一时刻只有一个节点在写。第二类是防重复操作。典型的例子是定时任务。很多系统里定时任务会部署在多台机器上做高可用但如果两台机器同时触发同一个任务就会重复执行。用分布式锁做一个抢锁机制抢到的节点执行其余节点跳过就能保证任务只跑一次。第三类是缓存击穿防护。缓存失效瞬间大量请求同时打到数据库可以用分布式锁让只有一个线程去重建缓存其他线程等待锁释放后直接读缓存。不过这里要注意锁的粒度太大会把性能拖垮后面会专门讲。我见过不少团队把分布式锁当成万能药结果锁倒是加了性能掉了好几倍。原因大多数是锁的粒度没设计好——本来锁一个商品就够了结果把整个库存服务锁住了。这个坑后面会细说。2. Redisson 锁的核心原理从 SETNX 到 Lua 脚本2.1 为什么不能直接只用 SETNX网上很多文章教人用 Redis 实现分布式锁代码通常长这样Boolean flag redisTemplate.opsForValue() .setIfAbsent(lock:order:1001, uuid, Duration.ofSeconds(30)); if (flag) { try { // 业务逻辑 } finally { redisTemplate.delete(lock:order:1001); } }这段代码能跑但有问题。问题不在于 SETNX 本身而在于它只能算一个雏形锁离可以上线的分布式锁还差三件事可重入、自动续期、原子解锁。先说原子解锁。当前实现是先判断 value 再删除如果判断完了、删除之前锁过期了别的线程刚好拿到了锁这时你一个 delete 就把别人的锁删了这就是经典的锁误删问题。很多人会说我在 delete 前再 get 一次判断下不就行了但 get 和 delete 之间不是原子的判断完到删除之间依然有窗口期。再说到期时间。锁设置了 30 秒过期但业务逻辑跑了 40 秒锁已经自动释放了第二个线程进来了业务并发问题照样发生。你可能会想那我把过期时间设大一点设长了业务崩溃时锁长时间不释放设短了业务稍慢就锁不住永远处于两难。还有可重入性。Java 的 synchronized 和 ReentrantLock 都支持同一个线程重复加锁但上面的 SETNX 实现做不到。你在一个方法里加了锁又调用了另一个需要同一把锁的方法直接就死锁了——第一次加的锁还没释放第二次根本加不上。Redisson 解决这些问题的核心思路就是把加锁、解锁的多个操作放进 Lua 脚本里交给 Redis 原子执行。2.2 Lua 脚本加锁与解锁的原子性关键Redisson 加锁的核心是tryLockInnerAsync方法底层执行的是一段 Lua 脚本。简化后的加锁逻辑大概是这样if (redis.call(exists, KEYS[1]) 0) then redis.call(hset, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; if (redis.call(hexists, KEYS[1], ARGV[2]) 1) then redis.call(hincrby, KEYS[1], ARGV[2], 1); redis.call(pexpire, KEYS[1], ARGV[1]); return nil; end; return redis.call(pttl, KEYS[1]);这里有几个关键设计点。第一用的不是 String 类型的 key-value而是Hash 结构。key 是锁的名称field 是持有锁的线程标识UUID 线程IDvalue 是重入次数。这样做的好处是支持可重入——同一个线程再次加锁就执行hincrby把计数加一。第二整个判断逻辑都在 Redis 服务端完成。判断锁是否存在、是否属于当前线程、设置过期时间这些操作在一个 Lua 脚本里原子执行不存在多个客户端指令之间的并发窗口。Redis 单线程执行脚本天然不会出现先读到不存在、然后别的线程也读到不存在的情况。第三加锁时用的是pexpire设置毫秒级过期时间默认值是 30 秒左右。但是过期时间并不是一次性给死的后面有看门狗机制动态续期。解锁的 Lua 脚本同样精巧if (redis.call(hexists, KEYS[1], ARGV[3]) 0) then return nil; end; local counter redis.call(hincrby, KEYS[1], ARGV[3], -1); if (counter 0) then redis.call(pexpire, KEYS[1], ARGV[2]); return 0; else redis.call(del, KEYS[1]); return 1; end;这段脚本先把重入次数减一如果计数大于 0 说明之前重入了多次只是退出一层锁还在如果计数等于 0说明所有加锁都解除了删除 key。释放锁之前还会检查锁的持有者是不是当前线程这就是刚才提到的锁误删问题的正解。2.3 看门狗机制与锁自动续期看门狗Watchdog是 Redisson 分布式锁里最容易被忽略、但最值得讲清楚的一个设计。先看它的背景。Lock 和 ReentrantLock 有tryLock的区别Redisson 也有类似逻辑lock()和lock()默认会启动一个后台任务来给锁续期。这个后台任务默认每 10 秒执行一次每执行一次就把锁的超时时间重新设置成 30 秒只要锁没被释放它就一直续期直到业务代码执行完走到 unlock。这个机制解决的是业务执行时间远超锁的过期时间的问题。没有看门狗的话你设 30 秒过期但业务跑了 50 秒第 30 秒时锁就自动释放了别的线程进来和你并发执行分布式锁形同虚设。有了看门狗锁的有效期被不断向后推移执行业务期间锁不会过期。使用lock()方法时看门狗是自动开启的但tryLock不一样。tryLock传入的leaseTime锁的持有时间如果大于 0Redisson 会认为你明确指定了锁的过期时间就不会启动看门狗——你都说了锁只持有 3 秒它就不会自作主张去续期。如果你调用tryLock时没有传leaseTime它才会走看门狗逻辑。这里有个实际建议生产环境我更喜欢显式传leaseTime而不是依赖看门狗。原因有两点一是看门狗是后台线程续期业务代码崩溃时锁最多撑 30 秒就释放如果业务逻辑里锁内操作有外部 RPC 调用这个时间会很尴尬二是显式传leaseTime时锁的释放时间是可以预期的排查问题时心智负担小很多。后面实操部分会给出推荐值。3. 实操指南搭建一套可上线的 Redisson 分布式锁3.1 环境准备与依赖引入Redisson 是 Java 生态的客户端要求 JDK 8 及以上Redis 服务端 3.0 以上版本。项目引入依赖很简单dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.4/version /dependency如果没有用 Spring Boot直接用核心依赖也可以dependency groupIdorg.redisson/groupId artifactIdredisson/artifactId version3.23.4/version /dependency接着配置 Redisson 客户端。最简单的玩法是直接声明一个RedissonClientBeanConfiguration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://127.0.0.1:6379) .setConnectionMinimumIdleSize(10) .setConnectionPoolSize(50) .setIdleConnectionTimeout(60000) .setConnectTimeout(10000) .setTimeout(3000); return Redisson.create(config); } }单机模式够大多数场景用了。如果你用的是 Redis 集群或哨兵模式配置方式也不同。集群模式配置成useClusterServers()哨兵模式用useSentinelServers()。建议根据自己的 Redis 部署形态选对应的配置不要一律用单机模式去连集群地址那会连不上。3.2 核心代码实现加锁、解锁与正确姿势引入 Redisson 后最简单的分布式锁代码长这样Autowired private RedissonClient redissonClient; public void deductStock(Long productId) { RLock lock redissonClient.getLock(lock:stock: productId); boolean isLocked false; try { // 尝试加锁最多等待3秒锁持有时间30秒 isLocked lock.tryLock(3, 30, TimeUnit.SECONDS); if (!isLocked) { throw new RuntimeException(系统繁忙请稍后重试); } // 临界区业务逻辑 // 扣减库存、生成订单等 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(获取锁被中断, e); } finally { if (isLocked) { lock.unlock(); } } }这段代码有几个细节值得展开说。第一tryLock(3, 30, TimeUnit.SECONDS)里的第一个参数是等待锁的时间即拿不到锁时最多阻塞多久。3 秒的意思是如果我拿不到锁最多等 3 秒还拿不到就放弃。这对用户体验很重要——好比你排队吃饭如果再等 5 分钟还没位置你就不等了。设为 0 就是拿不到锁立刻返回不等待。第二unlock必须放在finally里而且只有在拿到了锁的情况下才释放。很多同学写代码时忘了判断isLocked就盲目解锁结果在加锁失败的时候把别人持有的锁给解了这就是把自己代码里的小 bug 变成了线上故障。第三业务逻辑里的事务要和锁的边界对齐。一个常见的坏习惯是锁内购物车查询、库存查询、扣减都在事务里执行锁外面提交事务。锁本身只是互斥控制事务是数据一致性保证两者边界不对齐可能导致一个线程释放锁后事务还没提交另一个线程加锁成功读到旧数据。正确做法是先开事务在事务内加锁最后在事务提交后释放锁保证释放锁后数据已经对全局可见。3.3 参数选择与配置细节锁的过期时间、等待时间怎么设网上说法很多我给出一套基于实践的推荐参考值参数推荐值说明leaseTime30~60 秒取决于锁内业务的最长耗时建议取最长耗时的 2~3 倍waitTime1~5 秒取决于同一时刻竞争锁的线程数量和业务容忍度锁粒度业务维度库存锁商品订单锁用户不要全系统共用一个锁锁的 key 命名业务前缀:实体ID如 lock:stock:1001方便排查问题leaseTime 取值的关键是准确评估锁内代码的耗时。如果锁内有外部 HTTP 调用或数据库慢查询耗时上不封顶那设置固定过期时间意义不大要么调长过期时间要么考虑用看门狗模式。但如果锁内都是内存操作和单条 SQL耗时一般都会在 100ms 以内那么 30 秒的过期时间其实非常充裕完全不用续期。waitTime 的取值影响的是系统能承受多少并发请求排队等锁。假设你设置了 3 秒等待时间同一时刻有 100 个请求到达但每 100ms 只能处理一个那第 31 个请求之后的请求都会在 3 秒内等不到锁而直接失败。这可以当作一个天然限流机制过期时间越短失败越快系统越及时释放压力。还有一点容易被忽略Redisson 的锁默认是非公平的。非公平锁的含义是后来的线程可能插队获取锁。在锁竞争不激烈时非公平锁的性能更好因为它减少了线程挂起和唤醒的开销。但如果你的场景需要严格的先到先得比如票务系统的大量窗口抢购那可以看下面的公平锁方案。4. 常见问题与排查技巧实录4.1 锁没释放进程崩溃后 Redis 中的锁成为孤儿这是最常见的问题之一。业务线程获取锁后在执行逻辑过程中 JVM 崩溃或进程被 kill -9finally代码块还没来得及执行锁就一直躺在 Redis 里。有一种观点认为有 set 过期时间锁过 30 秒就自动释放了理论上没错但有个问题如果你没有设置 leaseTime、用了看门狗模式每 10 秒锁就会被续期只要持有锁的进程死了但看门狗线程还活着锁就会被无限续期下去。进程崩溃时JVM 里的所有线程都没了看门狗也没了锁自然不再续期会在最后一个续期时间点过期释放。所以任何锁都必须设置过期时间这是兜底方案。没有过期时间的分布式锁就是一颗定时炸弹。排查这类问题时最好的工具是 Redis 的monitor命令或者直接scan锁 key 查看 TTL scan 0 match lock:* count 100 1) 0 2) 1) lock:stock:1001 3) 2) lock:stock:1002 ttl lock:stock:1001 (integer) 23000如果你发现某个锁的 TTL 一直不变说明有看门狗在持续续期如果 TTL 为 -1说明锁没有设置过期时间。两种情况都是代码问题需要优先排查。4.2 锁误删明明锁是自己的却被别人删了锁误删的经典场景前面提过用 SETNX 原生实现时先 get 判断 value 再 delete判断和删除之间锁过期了别的线程拿到锁然后你把它的锁删了。Redisson 的 Lua 脚本从机制上避免了这个问题但如果你在代码里做了一些画蛇添足的封装就可能重新引入这个问题。比如有同事在 Redisson 之上又包了一层手动判断逻辑public void unlockWithCheck() { String value redisTemplate.opsForValue().get(lockKey); if (value.startsWith(redisson-client)) { lock.unlock(); } }这段代码既多了一次 Redis 网络请求又在判断与 unlock 之间留下了窗口期完全绕过了 Lua 脚本的原子性。正确做法是直接调用lock.unlock()把校验逻辑完全交给 Redisson。排查锁误删问题的另一个思路是检查 lock key 对应的 value。用hgetall查看 Hash 结构里的字段和值 hgetall lock:stock:1001 1) hash:2039a1c0-xxxx-xxxx-xxxx-xxxxxxxx 2) 1这里面的 field 是线程唯一标识格式通常是 UUID:线程ID。如果发现 field 里出现了两个不同 UUID说明确实有两个线程持有同一把锁——这就是并发控制失效了要赶紧查是不是手动删锁逻辑在作怪。4.3 锁的性能问题单点热点锁把整个集群拖垮分布式锁用起来很简单但性能问题极其隐蔽。有一家做促销活动的团队找我排查线上问题现象是活动一开始Redis 的 CPU 飙到 80%响应时间从几毫秒涨到上百毫秒。最后发现他们抢购逻辑的锁 key 是全局固定的RLock lock redissonClient.getLock(lock:sale:global);所有的用户抢购同一把全局锁Redis 的单线程模型下所有加锁解锁操作串行化QPS 一高就直接打满 CPU。这是分布式锁最常见的设计错误——锁的粒度太大。正确的锁粒度应该拆到业务维度。库存扣减锁商品 ID用户领取优惠券锁用户 ID同一个用户在活动期间的操作才算互斥不同用户之间互不影响。锁的 key 粒度越细竞争越分散Redis 的并发能力才能用起来。排查锁性能问题可以通过 Redis 的 latency 监控看看是不是LUA脚本耗时过高也可以统计一下锁的等待时间分布。如果在锁上等待的时间超过业务执行时间基本可以断定锁的粒度或并发设计有问题。4.4 锁的超时时间业务跑完了锁却提前过期锁的过期时间设置不当导致的诡异问题很有迷惑性业务代码明明没有报错但数据就是乱了。典型的场景是锁内业务耗时超过了锁的过期时间第二个线程在第一个线程还未结束时乘虚而入。排查这类问题有个非常好用的手段在业务代码里打印两个时间戳一个是加锁成功的时间一个是解锁前的时间看线程持有锁的总耗时。如果总耗时经常超过锁的过期时间说明锁的 leaseTime 确实设置得太短。租约时间调长是最粗暴的解决办法但会带来新的问题——如果线程阻塞了锁会长时间不释放其他线程要等很久才能拿到锁。更灵活的办法是给业务逻辑做拆分把锁内的大事务拆成多个小步骤每个步骤之间不需要持锁只把真正的互斥操作比如数据库 update放在锁内。锁内代码越短锁的租约时间越容易设置。5. 面试必备Redisson 分布式锁的高频考点5.1 关键考点盘点近几年分布式锁几乎是 Java 后端的必考题目围绕 Redisson 的面试题集中在几个问题上为什么不能用 SETNX 实现分布式锁这个问题考察三个点原子解锁、可重入、续期能力。你应该主动引出 Lua 脚本的可原子性再对比 SETNX 手动 delete 的竞态窗口。Redisson 的锁是公平的还是非公平的默认是非公平的。Redisson 也提供了FairLock类内部基于 Redisson 的延迟队列和信号量机制实现排队等待的线程按顺序获取锁。面试时提到公平锁和非公平锁的取舍会显得你理解更深一层。看门狗机制是怎么实现的回答要点加锁后启动一个后台定时任务每 10 秒执行一次把锁的过期时间重置为默认值30 秒。要补充说明只有没传 leaseTime 的时候才启动看门狗传了就是在信任你设置的过期时间。Redis 主从切换时锁失效怎么办这是最深度的一个问题。单节点 Redis 上加了锁如果主节点宕机、从节点升主锁的 key 还没来得及同步到从节点新主节点上没有锁——另一个客户端就也能加锁成功。Redisson 提供了RedissonMultiLock联锁和RedissonRedLock红锁来解决本质上是向多个 Redis 节点加锁多数节点成功才算加锁成功。但要提醒你红锁在复杂的网络分区场景下存在争议实际生产中最稳妥的方案还是保证 Redis 主从同步的可靠性而不是寄希望于红锁解决一切。5.2 面试中如何把原理讲得有条理一个清晰的口述框架应该是先讲分布式锁解决什么问题再讲为什么原生 SETNX 不行然后讲 Redisson 的 Lua 脚本和 Hash 结构怎么解决最后讲看门狗和主从切换的边界问题。比如问到Redisson 为什么用 Hash 不用 String很多人答不上这个细节。Hash 的价值在于field 存放持有者的唯一标识value 存放重入次数。同一线程重入时hincrby加一解锁时减一减到零才删除 key。如果用 String每次重入要重新 set 值可重入逻辑就没法做了。再比如问到客户端崩溃了锁怎么释放大多数人只想到过期时间兜底。更完整的回答是客户端崩溃时 JVM 线程和看门狗线程都没了锁会在下一次到期时间自动释放如果用的是没有过期的裸锁锁会永久存在这也是为什么 Redisson 强制给锁设置默认过期时间的原因。5.3 使用场景答题模板面试中的分布式锁使用场景基本是送分题但答得好不好有差距。我建议按三类场景 一个反例的框架回答资源互斥库存、金额、唯一流水号的并发控制防重复执行多节点定时任务、消息消费幂等缓存击穿防护热点 key 失效时只让一个线程重建缓存结合反例分布式锁只解决多个节点的互斥不解决事务一致性数据幂等问题不要把锁当成万能工具这样既展示了你对场景的理解又展示了你对分布式系统的整体思考。6. 经验总结几个我踩过的坑和调优心得做分布式锁这几年我把能踩的坑基本都踩了一遍最后分享几条自己的体会。第一不要过度设计锁方案。很多场景下用数据库唯一索引、乐观锁版本号就能解决并发问题比引入 Redisson 简单得多。分布式锁引入了 Redis 的依赖、网络延迟、故障转移问题如果并发量不大、对一致性要求不高优先用更简单的机制。第二生产环境优先显式设置 leaseTime。看门狗自动化续期看着方便但有个问题——锁的持有时间变得不可预测。你无法准确回答我这个锁最长会持有多久这在故障排查时非常致命。显式传 leaseTime锁的过期时间就是代码里出现过的那个数字哪个环节崩溃都是可推算的。第三锁的 key 命名要有业务语义。lock:stock:1001一眼能看出来是商品 1001 的库存锁。不要用纯随机字符串或者全局共用一个 key。上线排查问题时能通过 key 直接定位到具体业务模块能省半小时。第四监控锁竞争情况。我建议线上至少监控两个指标加锁失败率tryLock 返回 false 的比例和等待耗时。加锁失败率突增说明业务并发激增等待耗时过长说明锁粒度设计有问题或者临界区执行太慢。指标不监控出问题只能靠用户投诉反馈。第五留意锁内代码的耗时抖动。即使锁内业务平均耗时 100ms也可能出现单次耗时 5 秒的慢请求——可能是 GC 停顿、数据库慢查询、网络抖动。这种长尾请求会在不经意间让锁的过期时间失效。如果你的锁内涉及外部依赖可以考虑把 leaseTime 适当调大或者用看门狗模式续期不要死守设了 30 秒就一定安全。最后补充一点Redisson 不是只有RLock一种锁。它还提供读写锁RReadWriteLock、公平锁RFairLock、信号量RSemaphore和闭锁RCountDownLatch。读写锁在读多写少的场景很实用读读不互斥、读写互斥、写写互斥。比如配置中心多个节点并发读配置只有更新配置时才需要互斥用读写锁能把并发读的性能放出来。但读写锁实现复杂、bug 概率更高没有充分的性能测试验证之前我不建议贸然上生产环境。分布式锁本质上是分布式系统里一致性和可用性权衡的一个具体切口。理解它的原理比背几个 Redisson API 重要得多——因为你在生产环境遇到的每个诡异问题最后几乎都会绕回到锁的状态是否可控这个原始命题上。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

技能高考计算机调考系统部署全攻略:从环境摸底到故障排除 2026/9/26 19:17:20

技能高考计算机调考系统部署全攻略:从环境摸底到故障排除

2024年9月23日,我拿到了一份《技能高考计算机调考的安装相关说明》,文件名后缀的时间戳清清楚楚写着 20240923_104549,这是当天上午第四版。说句实在话,技能高考计算机类的调考系统安装,官方文档往往只写"服务端下…

阅读更多 →
Agent Skills工程化实战:从设计、测试到安全上线的完整指南 2026/9/26 19:17:20

Agent Skills工程化实战:从设计、测试到安全上线的完整指南

1. 为什么“写好”和“测好”是两件必须拆开做的事 很多人第一次接触 Agent Skills,脑子里想的都是“我写个脚本让 Agent 跑起来就完事了”。我一开始也这么想,结果上线第二天就被现实教育了。一个 Skill 从能跑到好用,中间隔着的不是代码量&…

阅读更多 →
古诗生成与情感分析闭环系统:基于LSTM和BERT的NLP实践 2026/9/26 19:17:20

古诗生成与情感分析闭环系统:基于LSTM和BERT的NLP实践

简介:面向自然语言处理与机器学习学习者,这份资源围绕古诗自动生成与情感分析,完整覆盖语料爬取、文本预处理、SPSS数据分析、规则作诗和机器学习写诗五个环节,适合做课程设计、毕业设计或入门NLP项目实践。压缩包内共156个文件&a…

阅读更多 →
AI自动化流程搭建实战:从需求拆解到落地优化 2026/9/26 19:17:20

AI自动化流程搭建实战:从需求拆解到落地优化

1. 先想清楚:你说的“AI自动化流程”到底指什么很多人一上来就问“怎么建AI自动化流程”,但这个问题本身太宽了。我做了几年AI应用落地,发现一个规律:问得越模糊,最后做出来的东西越没用。所以在动手之前,你…

阅读更多 →
VMware Workstation 17.5 安装与报错排查实战指南 2026/9/26 19:17:20

VMware Workstation 17.5 安装与报错排查实战指南

VMware Workstation 17.5 安装教程:从下载到报错排查,一篇讲透每年都有大量新手卡在虚拟机安装这一步,要么装完打不开,要么报一堆摸不着头脑的错误码。我最近刚给三台不同配置的电脑装完 Workstation 17.5,从老旧的四代…

阅读更多 →
unlazy门控契约完全指南:CHECK、EXPECT与EVIDENCE三大字段格式规范详解 2026/9/26 19:17:07

unlazy门控契约完全指南:CHECK、EXPECT与EVIDENCE三大字段格式规范详解

unlazy门控契约完全指南:CHECK、EXPECT与EVIDENCE三大字段格式规范详解 【免费下载链接】unlazy Anti-laziness skill for AI agents. Core: the Depth Tree method, which splits a task N layers deep and gives every leaf the full time budget of the whole ta…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉