第3章:Redis字符串 String 实战:验证码、计数器与分布式开关

1. 项目背景

1.1 业务场景

电商平台上线第一周,运营团队就提了三个紧急需求:

  1. 登录验证码:用户在登录时接收短信验证码,5 分钟后过期。每天都有一批用户投诉"验证码收不到",排查后发现是验证码校验逻辑每次都查数据库,高峰期数据库连接池耗尽导致校验超时。
  2. 防刷控制:秒杀活动期间,大量脚本自动注册账号并批量调用短信接口。运营要一个能力------同一手机号每分钟只能发送一次验证码,每天最多发送 10 次。
  3. 功能开关:运营想在后台一键控制"双十一活动入口"的展示。目前的方案是改数据库字段再重启服务,每次操作需要 5 分钟,线上活动切换完全没法做到实时。

这三个需求归纳起来其实是同一件事:高频读写的临时状态管理。它们有共同特征------数据量不大、生命周期短、读写频率高、不允许数据库成为瓶颈。Redis String 恰好是解决这类问题的最小可用方案。

1.2 痛点

如果不用 Redis 或者用错 Redis,会出现什么问题?

需求 错误方案 问题表现
验证码 存数据库(加 expire_time 字段) 每次校验走 SQL,高峰期 DB CPU 打到 90%;过期验证码需要定时任务清理
防刷计数 存在应用内存(HashMap) 服务重启计数清零;多实例部署时计数不一致
功能开关 存配置文件/数据库 变更需要重启或刷新缓存,无法做到秒级生效;多实例时配置不一致
通用问题 存 Redis 但不设 TTL 验证码 key 永不过期,内存持续上涨;日发送计数 key 无上限增长

真实案例:某电商平台曾将验证码存入 Redis 但未设置 TTL,一个月后 code:login:* 的 key 数量达到 300 万个,占用内存 1.2GB。运维发现时,业务 Redis 内存使用率已达 95%,新的缓存写入频繁触发淘汰,缓存命中率从 92% 骤降到 45%。

1.3 本章目标

掌握 Redis String 的核心命令和三种实战模式:

  • 验证码模式 :SET key value EX seconds + 校验后删除。
  • 计数器模式 :INCR/INCRBY 原子自增 + Lua 首次设过期。
  • 开关模式 :SET/GET 做分布式配置中心。

同时理解三个重要边界:

  • SET NX EX 的冷却语义 vs INCR 的计数语义------两者的适用场景完全不同。
  • 为什么验证码"校验成功要删除 key"------防止重复使用。
  • 为什么浏览量可以最终一致,库存不能随便 INCR/DECR------业务语义决定一致性要求。

2. 项目设计

2.1 第一幕:验证码存在哪里?

小胖 (拿出手机指着登录页面):"验证码存数据库不就行了?sms_code 表加一个 expire_time 字段,校验时 SELECT * FROM sms_code WHERE phone=? AND code=? AND expire_time > NOW()。逻辑清晰,还能留痕审计。"

小白 (摇头):"但每次登录都要走一次 SQL,如果登录 QPS 达到 5000,数据库就要额外承担 5000 次查询。而且还要写定时任务去清理过期验证码------你见过哪个项目记得写这个定时任务的?最后 sms_code 表里几十万条过期数据没人清理。"

小胖:"那 Redis 怎么做?把验证码存进去,它自己会过期?"

大师:"Redis 的一个核心能力就是 key 级的自动过期。你想想,验证码和餐厅的取餐号是一回事------过一会儿自动作废,不需要人工清理。"

sql 复制代码
MySQL 方案:
┌──────────┐  SELECT code   ┌──────────┐
│ 应用服务  │ ─────────────▶ │  MySQL   │
└──────────┘ ◀───────────── └──────────┘
    │         返回 code          │
    │                            │
    │  还要定时 DELETE           │
    ▼                            ▼
  SELECT ... WHERE expire_time > NOW()
  → 查询+索引扫描+行读取 → 毫秒级

Redis 方案:
┌──────────┐  GET code:login:mobile  ┌──────────┐
│ 应用服务  │ ──────────────────────▶ │  Redis   │
└──────────┘ ◀────────────────────── └──────────┘
    │         返回 code 或 nil            │
    │         (过期的自动返回 nil)         │
    ▼
  GET code:login:138xxxx → 内存直接读取 → 微秒级
  → 并且到时间自动删除,零维护成本

技术映射 :SET code:login:13800000000 9527 EX 300 做了三件事------写入验证码、设置 300 秒过期、到期 Redis 自动惰性/定期删除。

小胖:"那校验成功后要不要删除?我看有的人删,有的人不删。"

大师:"必须删。验证码和电影票一样------验过了就不能再用,否则别人捡到这张票还能再入场。如果一个验证码允许多次校验,攻击者可以用同一个验证码暴力尝试不同的手机号。"

2.2 第二幕:防刷是"冷却"还是"计数"?

小胖 :"防刷不就是在发短信之前检查一下'这个手机号最近发过没'吗?Redis 里的 SET NX(如果 key 不存在才写入)刚好能实现。"

大师 :"不完全。SET NX 解决的是'发过没有',返回的是布尔值------发了或没发。但业务要求是'每天最多 10 次',这需要知道'发了几次'。两种需求对应的 Redis 命令完全不同:"

ini 复制代码
场景一:一分钟冷却(有没有发过?)
┌─────────────┐
│  SET sms:cd:138xxxx 1 NX EX 60  │  → 返回 OK 或 nil
└─────────────┘
  OK   = 没发过,可以发
  nil  = 发过了,拒绝

场景二:每日次数限制(发了几次?)
┌─────────────┐
│  INCR sms:cnt:138xxxx:20260718  │  → 返回 1, 2, 3 ...
└─────────────┘
  ≤ 10 = 可以发
  > 10 = 超过限制

小白 追问:"INCR 和 EXPIRE 是两条命令吧?如果我 INCR 完后服务器宕机了,这个 key 是不是就没有过期时间了,变成永久的?"

大师:"这正是初学者最容易掉的坑。两条命令之间没有原子性保证,所以正确做法是用 Lua 脚本来保证:"

lua 复制代码
-- 原子地递增并设置首次过期
local current = redis.call('INCR', KEYS[1])
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current

技术映射 :SET NX 适合"开关式"冷却(能不能发),INCR 适合"计数式"限流(还能发几次)。两个语义不要混用。

2.3 第三幕:浏览量计数能随便 INCR 吗?

小胖 :"浏览量最简单了!每次商品详情接口被调用就 INCR product:view:1001,前端展示的时候 GET 一下。完事!"

小白:"那你考虑过 Redis 重启的问题吗?如果 Redis 因为升级或崩溃重启了,而且你没有及时把计数器落库,这几个小时的浏览量就全丢了。"

大师:"这取决于业务对'一致性'的要求。我给你个对比:"

数据类型 丢失容忍度 攒批策略 落库方式
商品浏览量 高(丢了无所谓) 每 30 秒/1000 次 GETDEL 刷库 异步批量 UPDATE
订单库存 零(绝对不能丢/错) 不攒批,实时用 Lua 扣减 同步写数据库 + 异步对账
接口调用次数 中(丢了影响报表) 每分钟 INCR + 定时刷 异步批量 INSERT
用户积分 低(涉及资产) 用 Stream 事件落库后再加 事件溯源 + 幂等重放

大师 :"所以浏览量的正确做法是------Redis 只管接住高频写入,设置一个 TTL 兜底。后台有个任务每隔 30 秒用 GETDEL(获取并删除)批量读取并刷回数据库。如果你觉得 30 秒太长,可以缩短到 5 秒,本质是'攒够一批再批量写数据库'。Redis 和数据库之间做了一个'缓冲池'。"

技术映射 :INCR 是原子自增,但每次调用都会产生网络往返。高并发场景下可以用 INCRBY key N 在应用层攒一批再发一次,但要注意去重和幂等。

2.4 第四幕:分布式开关的正确姿势

小胖 :"开关最简单了!SET switch:activity:double11 on,前端判断一下就行了。但如果 Redis 挂了怎么办?活动入口是展示还是不展示?"

大师 :"这是分布式开关最容易被忽视的一点------降级策略。你的业务逻辑必须有明确的默认行为:"

java 复制代码
// 危险写法:Redis 挂了就抛异常
String status = redis.get("switch:activity:double11");
if ("on".equals(status)) {
    showBanner();
}

// 安全写法:Redis 挂了有默认策略
String status = null;
try {
    status = redis.get("switch:activity:double11");
} catch (Exception e) {
    log.warn("Redis不可用,开关使用默认值");
}
// 高频活动:默认关闭,Redis 正常时才开启(保守策略)
// 核心功能:默认开启,Redis 不可用也不影响(激进策略)
boolean enabled = "on".equals(status) || (status == null && defaultOn);
if (enabled) {
    showBanner();
}

大师:"默认策略的选择规则很简单------如果这个功能开启后出了问题(比如大促活动入口),默认关闭更安全;如果这个功能关闭后影响用户体验(比如支付入口),默认开启更安全。原则是:让 Redis 的故障影响面最小化。"

技术映射:Redis 作为开关存储时,必须被视为"可能不可用"的外部依赖。优雅的实现是本地缓存 + Redis + 默认值三级保障。


3. 项目实战

3.1 环境准备

确保第 2 章的 docker-compose 环境已启动:

bash 复制代码
cd redis-lab
docker compose up -d
docker compose ps
# 确认 redis-lab 状态为 Up (healthy)

连接 Redis:

bash 复制代码
docker exec -it redis-lab redis-cli -a redis123

3.2 实战一:短信验证码(完整生命周期)

步骤 1:发送验证码

bash 复制代码
# 生成 6 位随机验证码并存入 Redis,5 分钟过期
127.0.0.1:6379> SET code:login:13800000000 9527 EX 300
OK

# 确认写入成功
127.0.0.1:6379> GET code:login:13800000000
"9527"

# 查看剩余过期时间(秒)
127.0.0.1:6379> TTL code:login:13800000000
(integer) 297

# 转为毫秒查看
127.0.0.1:6379> PTTL code:login:13800000000
(integer) 295678

步骤 2:校验验证码

bash 复制代码
# 获取存储的验证码
127.0.0.1:6379> GET code:login:13800000000
"9527"

# 用户输入和存储值一致 → 校验成功
# 校验成功后立即删除 key,防止二次使用
127.0.0.1:6379> DEL code:login:13800000000
(integer) 1

步骤 3:验证过期自动删除

bash 复制代码
# 写入一个 10 秒过期的验证码
127.0.0.1:6379> SET code:login:13800000001 1234 EX 10
OK
127.0.0.1:6379> TTL code:login:13800000001
(integer) 8

# 等待 10 秒后
127.0.0.1:6379> GET code:login:13800000001
(nil)    # Key 已自动删除

# 查看过期统计(INFO 命令中可以看到 expired_keys 增加)
127.0.0.1:6379> INFO stats | findstr "expired_keys"
# expired_keys:42

步骤 4:业务代码的实现要点

完整的验证码校验逻辑需要考虑边界情况。以下是 Java 伪代码:

java 复制代码
public class SmsCodeService {
    private static final String CODE_PREFIX = "code:login:";
    private static final int CODE_TTL_SECONDS = 300;  // 5分钟

    public boolean verifyCode(String phone, String inputCode) {
        String key = CODE_PREFIX + phone;
        String savedCode = redis.get(key);

        // 边界1:key 不存在 → 过期或未发送
        if (savedCode == null) {
            throw new BizException("验证码已过期,请重新获取");
        }

        // 边界2:验证码不匹配
        if (!savedCode.equals(inputCode)) {
            throw new BizException("验证码错误");
        }

        // 校验成功 → 删除 key,防止二次使用
        redis.del(key);
        return true;
    }

    public void sendCode(String phone) {
        // 发送前先检查冷却时间(见下一节)
        String code = RandomUtils.generateCode(6);
        String key = CODE_PREFIX + phone;
        redis.set(key, code, Duration.ofSeconds(CODE_TTL_SECONDS));
        smsGateway.send(phone, code);
    }
}

常见坑:

  • 验证码是敏感数据,日志中需要脱敏(只打印 phone=138****0000)。
  • 前端的倒计时和 Redis 的 TTL 是两套机制------前端倒计时是 UX 优化,Redis TTL 才是真正的安全边界。
  • 同一手机号短时间内发送多次,旧验证码会被覆盖------这合乎预期(以最新的为准),但要确保前端提示用户以最新收到的为准。

3.3 实战二:防刷控制------双重限制

需求:同一手机号每分钟只能发 1 次,每天最多发 10 次。前者用冷却实现,后者用计数器实现。

步骤 1:一分钟冷却(SET NX EX)

bash 复制代码
# 尝试写入冷却标记
127.0.0.1:6379> SET sms:cooldown:13800000000 1 NX EX 60
OK    # 返回 OK → 一分钟内没发过,允许发送

# 立即再次尝试(测试拦截)
127.0.0.1:6379> SET sms:cooldown:13800000000 1 NX EX 60
(nil) # 返回 nil → 冷却期未过,应该拒绝

# 60 秒后
127.0.0.1:6379> SET sms:cooldown:13800000000 1 NX EX 60
OK    # TTL 过期后可以重新发送

# 查看冷却剩余时间(TTL)
127.0.0.1:6379> TTL sms:cooldown:13800000000
(integer) 43

步骤 2:每日次数限制(INCR + Lua)

bash 复制代码
# 获取今天的日期作为 key 后缀
# key = sms:daily:13800000000:20260718

# 单独的 INCR------有风险(key 可能没 TTL)
127.0.0.1:6379> INCR sms:daily:13800000000:20260718
(integer) 1

# 使用 Lua 保证 INCR + EXPIRE 原子性
127.0.0.1:6379> EVAL "local c=redis.call('INCR',KEYS[1]); if c==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]); end; return c" 1 sms:daily:13800000000:20260718 86400
(integer) 1

# 再次调用------应该返回 2
127.0.0.1:6379> EVAL "local c=redis.call('INCR',KEYS[1]); if c==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]); end; return c" 1 sms:daily:13800000000:20260718 86400
(integer) 2

# 查看计数和 TTL
127.0.0.1:6379> GET sms:daily:13800000000:20260718
"2"
127.0.0.1:6379> TTL sms:daily:13800000000:20260718
(integer) 86368

步骤 3:Java 防刷逻辑(完整代码)

java 复制代码
public class SmsRateLimiter {
    private static final int DAILY_LIMIT = 10;
    private static final int COOLDOWN_SECONDS = 60;
    private static final int DAY_SECONDS = 86400;

    // Lua 脚本:原子执行 INCR + 条件 EXPIRE
    private static final String INCR_WITH_EXPIRE_LUA =
        "local c = redis.call('INCR', KEYS[1]) " +
        "if c == 1 then " +
        "    redis.call('EXPIRE', KEYS[1], ARGV[1]) " +
        "end " +
        "return c";

    public boolean checkAndIncr(String phone) {
        // 第一步:检查一分钟冷却
        String cooldownKey = "sms:cooldown:" + phone;
        Boolean ok = redis.setIfAbsent(cooldownKey, "1", Duration.ofSeconds(COOLDOWN_SECONDS));
        if (ok == null || !ok) {
            // 冷却期内,拒绝发送
            int remainSec = redis.ttl(cooldownKey).intValue();
            throw new BizException("操作太频繁,请" + remainSec + "秒后再试");
        }

        // 第二步:检查每日次数限制
        String dailyKey = "sms:daily:" + phone + ":" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
        Long count = redis.eval(INCR_WITH_EXPIRE_LUA,
            Collections.singletonList(dailyKey),
            Collections.singletonList(String.valueOf(DAY_SECONDS)));

        if (count > DAILY_LIMIT) {
            // 超额了,先退还冷却标记
            redis.del(cooldownKey);
            throw new BizException("今日发送次数已达上限(" + DAILY_LIMIT + "次)");
        }

        return true;
    }
}

关键设计要点:

  • 先检查冷却、再检查每日次数------冷却失败就直接拒绝,省去一次 EVAL。
  • 每日次数超限时,退还冷却标记------否则用户会被无意义地冷却 60 秒。
  • String.valueOf(DAY_SECONDS) 作为 Lua 的 ARGV1 传入------避免硬编码在 Lua 中。

3.4 实战三:商品浏览量计数器

步骤 1:基础自增

bash 复制代码
# 每次访问商品详情时自增
127.0.0.1:6379> INCR product:view:1001
(integer) 1
127.0.0.1:6379> INCR product:view:1001
(integer) 2

# 批量增加(应用层攒 10 次后发一次请求,减少网络 RTT)
127.0.0.1:6379> INCRBY product:view:1001 10
(integer) 12

# 读取当前浏览量
127.0.0.1:6379> GET product:view:1001
"12"

步骤 2:使用 GETDEL 获取并重置

bash 复制代码
# 写入测试数据
127.0.0.1:6379> INCRBY product:view:2001 50
(integer) 50

# GETDEL:获取当前值并删除 key(原子操作)
127.0.0.1:6379> GETDEL product:view:2001
"50"
127.0.0.1:6379> GET product:view:2001
(nil)    # key 已删除

# 如果 Redis 版本不支持 GETDEL,用 Lua 替代
127.0.0.1:6379> EVAL "local v=redis.call('GET',KEYS[1]); redis.call('DEL',KEYS[1]); return v" 1 product:view:2002

步骤 3:定时刷库方案

java 复制代码
public class ViewCountSyncTask {

    @Scheduled(fixedRate = 30_000)  // 每 30 秒执行
    public void syncToDatabase() {
        // 1. 扫描所有商品浏览量 key
        Set<String> keys = scanKeys("product:view:*");

        for (String key : keys) {
            // 2. 原子获取并重置计数器
            String count = redis.getDel(key);
            if (count != null && Integer.parseInt(count) > 0) {
                String productId = key.replace("product:view:", "");
                // 3. 批量累积后写入数据库
                batchBuffer.add(productId, Integer.parseInt(count));
            }
        }

        // 4. 批量写入数据库
        batchBuffer.flush((productId, delta) ->
            jdbcTemplate.update(
                "UPDATE product SET view_count = view_count + ? WHERE id = ?",
                delta, productId
            )
        );
    }
}

常见坑:

  • GETDEL 在 Redis 6.2.0 之后才可用,如果你的版本更老,用 Lua 实现。
  • 定时任务的间隔需要在"数据丢失容忍度"和"数据库写入频率"之间权衡,30 秒是一个常见的平衡点。
  • 数据库的 UPDATE ... SET view_count = view_count + ? 利用了行级锁的原子性,避免了并发覆盖问题。

3.5 实战四:分布式功能开关

步骤 1:基本读写

bash 复制代码
# 开启活动入口
127.0.0.1:6379> SET switch:activity:double11 on
OK

# 关闭活动入口
127.0.0.1:6379> SET switch:activity:double11 off
OK

# 读取状态
127.0.0.1:6379> GET switch:activity:double11
"on"

# 查看所有开关
127.0.0.1:6379> KEYS switch:*
# 生产环境请用 SCAN 替代
127.0.0.1:6379> SCAN 0 MATCH switch:* COUNT 20

步骤 2:带降级的开关实现(Java)

java 复制代码
public class FeatureSwitch {
    // 默认策略:true=核心功能默认开启,false=非核心功能默认关闭
    private final Map<String, Boolean> defaultStrategy = Map.of(
        "switch:activity:double11", false,   // 营销活动,默认关闭
        "switch:payment", true,              // 支付入口,默认开启
        "switch:livechat", false             // 在线聊天,默认关闭
    );

    public boolean isEnabled(String switchKey) {
        try {
            String value = redis.get(switchKey);
            if (value != null) {
                return "on".equalsIgnoreCase(value);
            }
        } catch (Exception e) {
            // Redis 不可用时的监控告警
            metricsCollector.record("switch.redis.unavailable", switchKey);
        }
        // 降级到默认策略
        return defaultStrategy.getOrDefault(switchKey, false);
    }
}

步骤 3:本地缓存优化

对于高频读取的开关(比如每次请求都需要判断),可以加一层本地缓存减少 Redis 访问:

bash 复制代码
# 先设置开关
127.0.0.1:6379> SET switch:activity:double11 on
OK

# 查看 key 对应的 TTL(-1 表示未设置过期)
127.0.0.1:6379> TTL switch:activity:double11
(integer) -1
java 复制代码
public class FeatureSwitchWithCache {
    private final LoadingCache<String, Boolean> localCache = Caffeine.newBuilder()
        .expireAfterWrite(5, TimeUnit.SECONDS)   // 本地缓存 5 秒
        .maximumSize(1000)
        .build(this::loadFromRedis);

    private Boolean loadFromRedis(String switchKey) {
        try {
            String value = redis.get(switchKey);
            return "on".equalsIgnoreCase(value);
        } catch (Exception e) {
            return getDefault(switchKey);
        }
    }

    public boolean isEnabled(String switchKey) {
        try {
            return localCache.get(switchKey);
        } catch (Exception e) {
            // Caffeine 加载失败时降级到默认策略
            return getDefault(switchKey);
        }
    }
}

常见坑:

  • 开关不要使用 EXPIRE------开关是长期存在的配置,设了 TTL 反而会在到期后回退到默认值,造成意外的功能切换。
  • 本地缓存 TTL 不宜太长(建议 1-5 秒),否则开关变更后不能及时生效。
  • 核心功能开关的降级策略必须是"默认开启",确保 Redis 故障不会导致核心功能不可用。

3.6 实战五:字符串的其他常用命令

bash 复制代码
# MGET:一次读取多个 key(减少 RTT)
127.0.0.1:6379> MSET user:name:1 "小胖" user:level:1 "5" user:city:1 "杭州"
OK
127.0.0.1:6379> MGET user:name:1 user:level:1 user:city:1
1) "小胖"
2) "5"
3) "杭州"

# 字符串长度(字节数,不是字符数)
127.0.0.1:6379> SET hello "你好世界"
OK
127.0.0.1:6379> STRLEN hello
(integer) 12    # UTF-8: 每个中文 3 字节,4 个中文 = 12

# 追加内容
127.0.0.1:6379> APPEND hello "!"
(integer) 13
127.0.0.1:6379> GET hello
"你好世界!"

# 子串操作(字节偏移,不是字符偏移!)
127.0.0.1:6379> SET data "Hello Redis"
OK
127.0.0.1:6379> GETRANGE data 0 4
"Hello"
127.0.0.1:6379> GETRANGE data 6 10
"Redis"

# 数值增减(非整数值会报错)
127.0.0.1:6379> SET count 100
OK
127.0.0.1:6379> INCR count
(integer) 101
127.0.0.1:6379> DECR count
(integer) 100
127.0.0.1:6379> INCRBY count 50
(integer) 150
127.0.0.1:6379> INCRBYFLOAT count 0.5
"150.5"

# 如果对非数值执行 INCR
127.0.0.1:6379> INCR hello
(error) ERR value is not an integer or out of range

3.7 实战六:观察内存编码差异

bash 复制代码
# 短字符串 (< 44 字节) → embstr 编码
127.0.0.1:6379> SET short_str "hello"
OK
127.0.0.1:6379> OBJECT ENCODING short_str
"embstr"
127.0.0.1:6379> MEMORY USAGE short_str
(integer) 57    # 约 57 字节

# 长字符串 (> 44 字节) → raw 编码
127.0.0.1:6379> SET long_str "这是一段超过了44个字节的很长很长很长的字符串内容,用来演示Redis的内部编码切换机制"
OK
127.0.0.1:6379> OBJECT ENCODING long_str
"raw"
127.0.0.1:6379> MEMORY USAGE long_str
(integer) 138   # 内存占用明显增加

# 整数值 → int 编码
127.0.0.1:6379> SET int_val 12345
OK
127.0.0.1:6379> OBJECT ENCODING int_val
"int"
127.0.0.1:6379> MEMORY USAGE int_val
(integer) 48    # 整数编码更紧凑

# 整数追加字符串 → 编码转换
127.0.0.1:6379> APPEND int_val "_abc"
(integer) 8
127.0.0.1:6379> OBJECT ENCODING int_val
"raw"           # 从 int 变成 raw

关键理解 :OBJECT ENCODING 让你看到 Redis 内部的存储编码,embstr 比 raw 更省内存,int 最紧凑。这解释了为什么同样的"字符串",不同内容占用不同内存------这是 Redis 优化性能的基础手段之一。


4. 项目总结

4.1 核心命令速查

命令 用途 复杂度 注意事项
SET key value [EX s] 写入字符串(可带过期) O(1) EX 参数是秒,PX 是毫秒
SET key value NX 仅 key 不存在时写入 O(1) 常用于分布式锁、冷却控制
GET key 读取字符串 O(1) key 不存在返回 nil
INCR key 原子自增 1 O(1) key 不存在时先设为 0 再自增
INCRBY key N 原子增加 N O(1) 支持负数(相当于 DECRBY)
MGET k1 k2 ... 批量读取 O(N) 减少网络往返,但一次别太多
GETDEL key 获取并删除 O(1) Redis 6.2+ 支持,用于计数器落库
STRLEN key 字符串字节长度 O(1) 返回字节数,不是字符数
APPEND key val 追加内容 O(1) 可能触发编码转换(embstr→raw)
TTL key 查看剩余过期秒数 O(1) -1=永不过期,-2=key 不在

4.2 优点与缺点

维度 详细说明
优点1:命令简单 SET/GET 就是键值对的读和写,学习成本极低,5 分钟可上手
优点2:原子自增 INCR 是天然原子操作,不需要应用层加锁,非常适合计数器
优点3:原生过期 SET ... EX 让临时数据自动消失,不需要定时任务清理
优点4:二进制安全 String 可以存文本、数字、JSON、序列化对象、图片字节
优点5:性能极高 单次 SET/GET 延迟通常在 0.1-0.3ms(局域网环境)
缺点1:局部更新弱 改一个字段必须整体读写,大对象适合用 Hash
缺点2:大 value 风险 value 超过 10KB 时每次网络传输成本高,建议拆分或压缩
缺点3:key 容易堆积 忘了设 TTL 的 key 会永不过期,必须靠规范约束
缺点4:无复杂查询 String 不能像 SQL 一样 WHERE 过滤,复杂的需要 Hash/ZSet

4.3 适用与不适用场景

适用场景:

  1. 临时凭证------验证码、Token、重置密码链接、一次性分享码。
  2. 高频计数器------浏览量、点赞数、接口调用次数、消息未读数。
  3. 分布式开关------功能灰度、活动入口、降级开关、AB 实验分组。
  4. 简单缓存------小对象序列化缓存、用户会话 Session、配置热加载。
  5. 分布式锁------SET NX EX 实现(将在第 18 章深入)。
  6. 限流控制------冷却窗口、频次控制、桶令牌计数器。

不适用场景:

  1. 需要频繁局部更新的对象------应该用 Hash(第 4 章)。
  2. 需要排序/范围查询的数据------应该用 ZSet(第 7 章)。
  3. 需要关系运算的数据------应该用 Set(第 6 章)。
  4. 单条 value 超过 10MB 的数据------String 不适合存大 value。
  5. 需要事务保证的多 key 操作------需要 Lua 或事务(第 13-14 章)。

4.4 常见踩坑经验

案例 表象 根因 解法
验证码可被重复使用 用户反馈"别人用我的验证码登录了我的账号" 校验成功后没有 DEL key,同一个验证码可多次校验 校验成功后必须 DEL;也可加时间窗口限制(如 1 分钟内最多校验 3 次)
计数器 key 泛滥 Redis 内存从 4GB 涨到 20GB,keys 数量 2000 万 inline INCR 在业务代码中大量使用,但不设 TTL 所有计数器 key 必须有 TTL 兜底;用 SCAN 定期巡检无 TTL key
短字符串性能误区 GET user:profile:xxx 返回整个 100KB JSON 对象 把完整用户资料序列化为 String,每次取任一字段都要整体传输 改用 Hash 拆分字段(第 4 章);或用 RedisJSON 做路径读取(第 39 章)
用 SET NX 做限频忘记 EX 用户反馈"收不到验证码",排查发现所有号码都被冷却了 SET key 1 NX 成功写入但忘记加 EX,key 永不过期 SET key 1 NX EX 60 必须同时指定过期时间
APPEND 后的编码退化 一个简单 String 从 embstr 退化为 raw 后内存占用翻倍 APPEND 操作会触发编码从 embstr 转为 raw,且不可逆 预先估算 value 大小,如果可能被多次追加,直接用 raw 也无妨

4.5 思考题

  1. 设计题 :你需要在 Redis 中实现一个"每小时每个 IP 最多访问 100 次"的接口限流。请设计 key 命名、命令组合和过期策略。你会使用 INCR 还是 SET NX?为什么?

  2. 故障题 :线上同事反馈功能开关"有时候不生效"。经过排查,开关 key switch:feature:new 的 value 确实是 on,但某些请求中 redis.get("switch:feature:new") 返回了 null。请列举至少 3 种可能导致此现象的原因及修复方案。

(答案提示将在下一章末尾给出。)

上一章思考题答案提示(第2章):

第1题 (迁移到 Linux 生产需要修改的配置至少 5 项):① bind 从 0.0.0.0 改为内网 IP;② requirepass 改为生产强密码;③ appendfsync 可能保持 everysec;④ 增加 rename-command FLUSHALL "" 禁用危险命令;⑤ maxmemory 按服务器实际内存重新规划;⑥ protected-mode 配合 bind 调整;⑦ 取消端口映射(生产用宿主机直连或 K8s Service)。

第2题 (unhealthy 排查):① docker logs redis-lab 查看启动日志,确认是启动失败还是健康检查失败;② docker exec redis-lab redis-cli -a redis123 PING 手动验证 Redis 是否响应;③ 检查 redis.conf 中密码是否与 healthcheck 中密码一致(healthcheck 里是 redis123);④ 检查 data/ 目录权限是否允许 Redis 写入;⑤ 检查是否因 AOF 文件损坏导致启动失败(清空 data/ 重试)。

延伸阅读与资源

Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统

下一章预告:第4章------哈希 Hash 实战:用户画像与购物车对象存储。

相关推荐
Sayuanni%36 小时前
Redis(初阶)
数据库·redis·缓存
未秃头的程序猿7 小时前
一次重复扣款的复盘:分布式锁加错了维度,比不加锁更危险
java·redis·后端
hweiyu001 天前
Redis命令:LLEN
redis·缓存
VX_bysjlw9851 天前
基于协同过滤的美食推荐系统的设计与实现32221-计算机毕设原创(免费领源码+带部署教程)
java·spring boot·redis·mysql·tomcat·maven·mybatis
hweiyu001 天前
Redis命令:RPUSHX
redis·缓存
星夜孤帆1 天前
Redis缓存击穿与分布式锁实战:专栏目录、源码与实验说明
redis·分布式·缓存
clz13145211 天前
ruoyi-cloud功能开发demo实战:三级联动 Redis Hash 缓存查看器
redis·缓存·哈希算法
Code_Artist2 天前
论反射编程的魅力——程序如何在运行时认识自己?
后端·源码·编程语言