提到 Redis,很多人的第一反应是”缓存”。但 Redis 真正值钱的能力,远不止挡在数据库前面那一层。高并发系统里,它既是最稳妥的分布式锁载体,也自带 Bitmap、HyperLogLog、GEO 等”省内存又高效”的高级数据结构。本文先把分布式锁的正确姿势讲透,再带你用三类高级结构解决真实业务问题——这些恰恰是面试和架构评审里的高频考点。
一、为什么需要分布式锁
当同一份资源被多台机器、多个进程同时修改时,单机锁(如 Java 的 synchronized、ReentrantLock)只防得住线程,防不住跨进程。典型场景:秒杀扣库存、定时任务防重跑、订单状态机迁移。如果只在应用内存里加锁,实例 A 和实例 B 各锁各的,超卖照常发生。更隐蔽的是定时任务:多实例部署下每台到点都跑一遍对账,就会重复出账。把锁放到一个所有节点都能访问、且自带原子操作的第三方(Redis)上,就成了最通用的解法。它与Redis 缓存设计中的击穿防护是一体两面:一个是”别让请求同时打到 DB”,一个是”别让写操作同时发生”。
二、分布式锁的正确姿势
2.1 基础实现:SET NX PX 一条命令
锁的核心要求是”原子地:不存在才设置 + 带过期时间”。拆成两条命令(先 SETNX 再 EXPIRE)中间一旦进程挂掉,锁就永远不过期。Redis 2.6.12+ 把两者合成一条 SET key value NX PX,是底线写法。
// 反例:非原子,并发下可能同时拿到锁
if (!jedis.exists(lockKey)) jedis.set(lockKey, uuid);
// 正确:仅当不存在时设置,并带 30s 过期(防死锁)
String result = jedis.set(lockKey, uuid, "NX", "PX", 30000);
if ("OK".equals(result)) {
try {
// 临界区:扣库存 / 状态迁移 / 防重跑
} finally {
releaseLock(jedis, lockKey, uuid);
}
} else {
// 没拿到锁:快速失败或自旋退避,别无限阻塞
}
2.2 误删与原子释放:必须用 Lua
释放锁时如果”先 GET 比对 value,再 DEL”,这两步之间锁可能已过期、被别人抢走,于是你删掉了别人的锁。务必用 Lua 脚本把”比对 + 删除”打包成原子操作,value 用唯一 UUID 标识自己。
// 释放锁:只有 value 等于自己的 uuid 才删除,杜绝误删
private static final String RELEASE_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ " return redis.call('del', KEYS[1]) "
+ "else return 0 end";
Object r = jedis.eval(RELEASE_LUA,
Collections.singletonList(lockKey),
Collections.singletonList(uuid));
// r == 1 表示成功释放;0 表示锁已不属于自己,无需处理
2.3 Redlock 与生产的取舍
单节点 Redis 挂了会丢锁,于是有了 Redlock:向 N 个独立节点申请锁,多数(>N/2)成功且总耗时小于锁有效期才算获取。Redisson 提供了现成实现,但要注意两点:时钟漂移和 GC 停顿会让”多数派”语义打折;高并发超卖场景,建议”锁 + 业务幂等/版本号”双保险,而不是只押宝在锁上。也别迷信 Redlock 能解决一切——它假设节点间时钟相对可信,在极端网络分区或长 STW 暂停下仍有理论漏洞。工程上锁更像”大概率互斥”的保险,关键路径还要靠数据库的唯一索引、乐观锁版本号兜底,这样即便锁失效也不会脏写。
// Redisson 的 RedLock:向多个独立 Redis 申请,多数成功才算持有
RLock lock1 = redissonClient1.getLock("order");
RLock lock2 = redissonClient2.getLock("order");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2);
boolean locked = redLock.tryLock(100, 30000, TimeUnit.MILLISECONDS);
try {
if (locked) { /* 临界区 */ }
} finally {
if (locked) redLock.unlock();
}
三、高级数据结构实战
除了 String/Hash,Redis 还内置了几个”为特定场景而生”的结构,用对了能省下大量内存和代码。它们和MySQL 深度调优关注的不同层:一个是持久化存储的索引与执行计划,一个是内存层的轻量计数与关系计算。
3.1 Bitmap:签到与在线状态
# 用户 10086 在 2026-08-28 签到(offset 即用户 id)
SETBIT sign:20260828 10086 1
# 当天签到总人数
BITCOUNT sign:20260828
# 连续三天签到:把多天位图做 AND,再统计
BITOP AND sign:cont sign:20260826 sign:20260827 sign:20260828
BITCOUNT sign:cont
3.2 HyperLogLog:UV 去重计数
统计页面 UV 如果存全量用户 id,内存爆炸。HyperLogLog 用固定约 12KB 空间做基数估算,标准误差约 0.81%,适合”大概多少”的场景。
# 记录当日 UV(重复 add 同一用户自动去重)
PFADD uv:20260828 user:10086 user:10087 user:10086
PFCOUNT uv:20260828
# 合并一周 UV
PFMERGE uv:week uv:20260822 uv:20260823 uv:20260824 uv:20260825 uv:20260826 uv:20260827 uv:20260828
PFCOUNT uv:week
3.3 GEO:附近的人 / LBS
# 写入站点经纬度(经度 纬度 成员)
GEOADD stations:sz 114.05 22.54 "station-a" 114.08 22.55 "station-b"
# 以给定坐标为中心,查 10km 内站点并带距离
GEORADIUS stations:sz 114.05 22.54 10 km WITHDIST
# 或以某个成员为中心
GEORADIUSBYMEMBER stations:sz "station-a" 10 km WITHDIST
四、性能与避坑
分布式锁要设置合理的超时(太短业务没跑完锁就释放,太长死锁恢复慢);高级结构要避开”大 key”——一个超大的 Bitmap 或 ZSET 会让单次命令耗时飙升,阻塞整实例。批量写入用 Pipeline 把上千条命令压成一次网络往返,吞吐提升一个量级。线上可用 redis-cli --bigkeys 定期巡检,发现超大 key 及时拆分或迁移;锁的 value 用随机 UUID 而非固定字符串,能进一步降低误删概率。另外,连接池的 maxTotal 与超时参数要按真实 QPS 压测设定,避免锁竞争高峰把连接耗尽、把获取锁也变成瓶颈。
# 把批量命令通过 pipe 一次性灌入,减少 RTT
cat cmds.txt | redis-cli --pipe
# 或用事务包裹多条写
MULTI
SET k1 v1
INCR counter
EXEC
| 结构 | 典型场景 | 时间复杂度 | 内存注意 |
|---|---|---|---|
| Bitmap | 签到 / 布尔状态 / 日活 | O(1) 位操作 | 偏移量越大占用越可控 |
| HyperLogLog | UV 去重 / 基数估算 | O(1) 添加 | 固定约 12KB |
| GEO | 附近的人 / LBS | O(log N + M) | 底层用 ZSET 存储 |
| Pipeline | 批量写入 | 一次往返 | 避免单批过大 |
五、实战避坑清单
- 锁必须带过期时间:只用
SET NX PX,杜绝”忘释放就永久死锁”。 - 释放必须原子:用 Lua 比对 value 再删除,别让”误删别人的锁”成为线上事故。
- 锁粒度要细:按业务维度(如 orderId)加锁,而非一把大锁串行化全部请求,否则吞吐骤降。
- 大 key 是性能杀手:避免单个 Bitmap/ZSET 无限膨胀,按天/按桶拆分。
- 结构选对场景:精确计数用 String/Hash,估算 UV 用 HyperLogLog,地理位置用 GEO,不要硬扛。
- 压测与校验进流水线:把锁竞争、大 key 扫描接进GitHub Actions 持续集成,让回归在合并前暴露。
Redis 的价值远不止缓存层那一块。把分布式锁做对(原子获取、原子释放、合理超时),再把 Bitmap / HyperLogLog / GEO 用在签到、UV、LBS 这类”轻量计数与关系”场景,你会发现很多原本要搬数据库、写复杂 SQL 的需求,一条 Redis 命令就解决了。前提是守住两条底线:锁一定要能自动释放且不被误删,结构一定要避开大 key 拖垮整实例。把这些写进团队的代码评审清单,相关线上故障通常会少一大半。




