Redis-07典型业务场景

07 典型业务场景

目录

🎯 学习要点

  • 验证码登录与频控
  • 点赞去重与关注关系
  • 排行榜与分页
  • 签到与活跃度
  • UV 统计与近似去重
  • 地理搜索与围栏
  • 会话与 Token 管理
  • 幂等与去重

📖 名词解释

  • 位图(Bitmap):用位记录布尔状态,适合签到这类“是否发生”的标记。
  • UV:独立访客数,指一天内来访的不同用户数量。
  • HyperLogLog:近似去重数据结构,用极小空间估算不同元素数量。
  • Geo:地理空间索引,用经纬度进行附近搜索与距离计算。
  • Token:登录后的凭证,用于标识会话身份并控制访问。
  • 幂等:同一操作重复执行结果一致,避免重复下单或重复扣费。

🧭 学习方案

  • 为每个场景写一个最小可运行的例子:验证码、点赞、排行榜、签到、UV、附近、会话、幂等。
  • 在示例中加入边界条件:例如重复点赞、重复验证码验证、分页越界。
  • 将数据定期同步到数据库或日志,形成持久化与可审计的闭环。
  • 汇总场景的键命名与 TTL 规范,避免键冲突与过期不一致。

🗺️ 应用场景说明

  • 验证码登录:像临时条形码,过期自动失效,防止“旧码”使用。
  • 点赞:像投票箱,Set 保证一个人投一次,统计总票数。
  • 排行榜:像成绩单,分数越高名次越靠前,取前 N 名展示。
  • 签到:像日历打勾,用位图记录每天是否完成任务。
  • UV:像门禁计数器,近似统计当天来过的人数,节省空间。
  • 附近搜索:像地图导航,按经纬度找离我最近的店。
  • 会话:像临时通行证,2 小时有效,可续期或强制作废。
  • 幂等:像一次性票据,用过一次就不能再用,防重复提交。

⚠️ 注意事项

  • 频控与黑名单策略
  • 键命名与生命周期
  • 幂等性保证与重复提交

🔐 验证码登录

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;

@Service
public class SmsLogin {
    @Autowired
    RedisTemplate<String, String> tpl;

    public void sendCode(String phone, String code) {
        // 写入验证码并设置 5 分钟过期,避免旧码被使用
        tpl.opsForValue().set("sms:" + phone, code, Duration.ofMinutes(5));
    }

    public boolean verify(String phone, String code) {
        // 读取验证码并比对;成功后可删除键或缩短 TTL
        String v = tpl.opsForValue().get("sms:" + phone);
        return code.equals(v);
    }

    public boolean allowSend(String phone) {
        // 频控:固定窗口 60 秒内限制最多 5 次发送
        String key = "ratelimit:sms:" + phone + ":60";
        Long c = tpl.opsForValue().increment(key);
        if (c != null && c == 1) {
            tpl.expire(key, Duration.ofSeconds(60)); // 首次计数时设置窗口 TTL
        }
        return c != null && c <= 5; // 阈值按业务配置
    }

    public void addBlacklist(String phone) {
        // 加入黑名单集合,禁止发送验证码
        tpl.opsForSet().add("blacklist:sms", phone);
    }

    public boolean isBlacklisted(String phone) {
        // 黑名单命中后直接拒绝
        Boolean b = tpl.opsForSet().isMember("blacklist:sms", phone);
        return Boolean.TRUE.equals(b);
    }

    public boolean sendCodeSafely(String phone, String code) {
        // 先黑名单拦截,再做频控,最后才写入验证码
        if (isBlacklisted(phone)) {
            return false;
        }
        if (!allowSend(phone)) {
            return false;
        }
        tpl.opsForValue().set("sms:" + phone, code, Duration.ofMinutes(5));
        return true;
    }

    public boolean allowSendIp(String ip) {
        // IP 维度限流:固定窗口 60 秒内最多 20 次
        String key = "ratelimit:sms:ip:" + ip + ":60";
        Long c = tpl.opsForValue().increment(key);
        if (c != null && c == 1) {
            tpl.expire(key, Duration.ofSeconds(60)); // 首次计数时设置窗口 TTL
        }
        return c != null && c <= 20; // IP 阈值按业务配置
    }

    public boolean sendCodeSafely(String phone, String code, String ip) {
        // 结合 IP 维度限流进一步防刷:先黑名单,后手机与 IP 双限流
        if (isBlacklisted(phone)) {
            return false;
        }
        if (!allowSend(phone)) {
            return false;
        }
        if (!allowSendIp(ip)) {
            return false;
        }
        tpl.opsForValue().set("sms:" + phone, code, Duration.ofMinutes(5));
        return true;
    }
}

通俗说明

  • 发验证码后把值放入 Redis 并设置过期,验证时直接比对即可。
  • 频控与黑名单要配合使用,防止短信接口被刷。
详细解释
  • 键形如 sms:{phone},写入时设置 5 分钟过期,避免旧码被使用。
  • 频控用 计数+TTL,黑名单用 Set 管理异常号码,防刷更稳。
  • 手机 + IP 双限流组合,先黑名单后限流,进一步提升防刷效果。
  • 验证成功后建议删除验证码或缩短有效期。
速记口诀
  • 口诀:验证码写缓存,频控+黑名单

👍 点赞与取消

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;

@Service
public class LikesService {
    @Autowired
    RedisTemplate<String, String> tpl; 

    public boolean like(String newsId, String userId) {
        // 点赞写入 Set,天然去重;返回新增成员数
        Long r = tpl.opsForSet().add("like:news:" + newsId, userId);
        return r != null && r > 0;
    }

    public boolean unlike(String newsId, String userId) {
        // 取消点赞移除成员;返回移除成员数
        Long r = tpl.opsForSet().remove("like:news:" + newsId, userId);
        return r != null && r > 0;
    }
}

通俗说明

  • 点赞用 Set 天然去重;取消点赞用 remove 即可。
  • 展示总数用 size,高并发场景注意写入与读取一致性。
详细解释
  • 点赞键按内容分片 like:news:{id},成员为用户 ID,唯一性自然保证。
  • 取消点赞是移除成员;展示总数用 SCARD/size,大集合下注意性能。
  • 并发一致性可通过队列异步落库,避免强一致开销。
速记口诀
  • 口诀:点赞用 Set,取消就 remove

🏆 排行榜

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;
import java.util.Set;

@Service
public class RankService {
    @Autowired
    RedisTemplate<String, String> tpl; 

    public void addScore(String userId, double s) {
        // 增加分数(原子操作),适合累计得分与加权
        tpl.opsForZSet().incrementScore("rank:game", userId, s);
    }

    public Set<String> top(int n) {
        // 取前 N 名(按分数倒序范围)
        return tpl.opsForZSet().reverseRange("rank:game", 0, n - 1);
    }
}

通俗说明

  • 排行榜用 ZSet,分数越高排名越靠前;取 TopN 用倒序范围。
  • 分数设计要可叠加,避免浮点误差带来排序抖动。
详细解释
  • 分数可做加权或时间衰减,设计好累加策略与精度控制。
  • 读取 TopN 用倒序范围,带分数展示更直观;分页需控制范围与内存。
  • 并发写需使用原子增分接口,避免排名抖动。
速记口诀
  • 口诀:分数定名次,倒序取前 N

🗓️ 签到位图

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;

@Service
public class SignCalendar {
    @Autowired
    RedisTemplate<String, String> tpl;

    public void sign(String userId, int dayIndex) {
        // 将当日偏移位置设为 1,表示已签到
        tpl.opsForValue().setBit("signin:2025-11:" + userId, dayIndex, true);
    }

    public Long days(String userId) {
        // 统计位图中为 1 的位数(签到天数);使用底层连接执行 BITCOUNT
        return tpl.execute(c -> c.bitCount(("signin:2025-11:" + userId).getBytes()));
    }
}

通俗说明

  • 每天一个位,签到就把对应位设为 1;统计用位计数即可。
  • 偏移从 0 开始,注意与日历日期的对应关系。
详细解释
  • 键形如 signin:{yyyy-MM}:{uid},偏移按当天序号映射(0 起)。
  • 统计用 BITCOUNT;连续天数与补签可通过位运算实现。
  • 月度数据可加 TTL 或归档,避免长期占用内存。
速记口诀
  • 口诀:一位一天,计数看 BITCOUNT

📈 UV 统计 HLL

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.data.redis.core.RedisTemplate;

@Component
public class UVService {
    @Autowired
    RedisTemplate<String, String> tpl; 

    public void visit(String day, String uid) {
        // 记录唯一访问(近似去重),极省内存
        tpl.opsForHyperLogLog().add("uv:" + day, uid);
    }

    public long uv(String day) {
        // 获取近似 UV 规模;为 null 时返回 0
        Long c = tpl.opsForHyperLogLog().size("uv:" + day);
        return c == null ? 0 : c;
    }
}

通俗说明

  • HLL 用极小内存近似统计 UV,有轻微误差但可接受。
  • 适合访问统计,不用于强一致性结算。
详细解释
  • 记录用 PFADD,统计用 PFCOUNT;误差约 1% 可接受。
  • 比 Set 更省内存,适合千万级 UV 场景;不用于账务结算。
  • 结果为近似值,需明确业务使用边界。
速记口诀
  • 口诀:近似去重很省内存,UV 首选 HLL

📍 Geo 附近

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Metrics;
import org.springframework.data.geo.Circle;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.connection.RedisGeoCommands.GeoRadiusCommandArgs;
import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation;
import org.springframework.data.redis.core.RedisGeoCommands;
import org.springframework.data.redis.core.GeoResults;
import java.util.ArrayList;
import java.util.List;
import java.util.Comparator;

@Service
public class NearbyService {
    @Autowired
    RedisTemplate<String, String> tpl;

    /**
     * 当前位置与半径搜索附近结果
     */
    public void add(String name, double lon, double lat) {
        // 添加门店坐标(经纬度+名称)
        tpl.opsForGeo().add("geo:shop", new Point(lon, lat), name);
    }

    public Object search(double lon, double lat) {
        // 以当前位置为圆心、5km 半径搜索附近门店
        return tpl.opsForGeo().radius("geo:shop", new Point(lon, lat), new Distance(5, Metrics.KILOMETERS));
    }

    

    /**
     * 分页按距离排序
     */
    public Object searchPageByDistance(double lon, double lat, double radiusKm, int page, int size) {
        // 构造圆形查询范围(半径单位:公里)
        Circle circle = new Circle(new Point(lon, lat), new Distance(radiusKm, Metrics.KILOMETERS));
        // 包含距离、按距离升序排序;limit 取到当前页的末尾,再做本地分页切片
        GeoRadiusCommandArgs args = GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().sortAscending().limit(page * size + size);
        GeoResults<GeoLocation<String>> results = (GeoResults<GeoLocation<String>>) tpl.opsForGeo().radius("geo:shop", circle, args);
        List<GeoResults<GeoLocation<String>>.GeoResult<GeoLocation<String>>> list = results.getContent();
        int from = Math.min(page * size, list.size()); // 起始下标
        int to = Math.min(from + size, list.size());   // 结束下标
        return list.subList(from, to); // 返回当前页的结果切片
    }

    

    /**
     * 距离 + 评分组合排序
     */
    public List<String> searchByDistanceAndRating(double lon, double lat, double radiusKm, int size, double alpha) {
        // 取半径内候选并包含距离,用距离与评分组合计算排序分数
        Circle circle = new Circle(new Point(lon, lat), new Distance(radiusKm, Metrics.KILOMETERS));
        GeoRadiusCommandArgs args = GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance();
        GeoResults<GeoLocation<String>> results = (GeoResults<GeoLocation<String>>) tpl.opsForGeo().radius("geo:shop", circle, args);
        class Scored { String id; double s; Scored(String id, double s){this.id=id;this.s=s;} }
        List<Scored> scored = new ArrayList<>();
        for (var r : results) {
            String shop = r.getContent().getName();
            double distKm = r.getDistance().getValue();          // 距离(公里)
            Double rating = tpl.opsForZSet().score("shop:rating", shop); // 评分(0-5)
            // 组合分数:alpha 控制距离权重,评分越高分数越低(更优)
            double score = alpha * distKm + (rating != null ? (1 - rating / 5.0) * (1 - alpha) : (1 - alpha));
            scored.add(new Scored(shop, score));
        }
        // 按组合分数升序取前 size 个
        scored.sort(Comparator.comparingDouble(x -> x.s));
        List<String> out = new ArrayList<>();
        for (int i = 0; i < Math.min(size, scored.size()); i++) {
            out.add(scored.get(i).id);
        }
        return out;
    }



    /**
     * 城市分区存取(避免大键)
     */
    public void addToCity(String city, String name, double lon, double lat) {
        // 按城市维度分区存储,避免单键过大
        tpl.opsForGeo().add("geo:shop:" + city, new Point(lon, lat), name);
    }

    public Object searchInCity(String city, double lon, double lat, double radiusKm) {
        // 在城市分区内进行半径搜索,减少扫描范围
        return tpl.opsForGeo().radius("geo:shop:" + city, new Point(lon, lat), new Distance(radiusKm, Metrics.KILOMETERS));
    }



    /**
     * 坐标网格分层存储(避免大键)
     */
    private String tileKey(double lon, double lat, double sizeKm) {
        double sizeDeg = sizeKm / 111.0; // 近似换算:1°≈111km,将 km 划分为经纬网格大小
        int x = (int) Math.floor(lon / sizeDeg);
        int y = (int) Math.floor(lat / sizeDeg);
        return x + ":" + y; // 网格坐标键(x:y)
    }

    private List<String> tilesInRange(double lon, double lat, double radiusKm, double sizeKm) {
        double sizeDeg = sizeKm / 111.0; // 网格大小(度)
        double rDeg = radiusKm / 111.0;  // 查询半径(度)
        int minX = (int) Math.floor((lon - rDeg) / sizeDeg);
        int maxX = (int) Math.floor((lon + rDeg) / sizeDeg);
        int minY = (int) Math.floor((lat - rDeg) / sizeDeg);
        int maxY = (int) Math.floor((lat + rDeg) / sizeDeg);
        List<String> t = new ArrayList<>();
        for (int x = minX; x <= maxX; x++) {
            for (int y = minY; y <= maxY; y++) {
                t.add(x + ":" + y); // 收集覆盖范围内的所有网格键
            }
        }
        return t;
    }

    public void addPartitioned(String name, double lon, double lat, double sizeKm) {
        // 按网格分层存储,降低单键规模
        String part = "geo:shop:tile:" + tileKey(lon, lat, sizeKm);
        tpl.opsForGeo().add(part, new Point(lon, lat), name);
    }

    public List<Object> searchPartitioned(double lon, double lat, double radiusKm, double sizeKm) {
        // 枚举覆盖范围内的网格键,分别执行半径搜索并聚合结果
        List<String> tiles = tilesInRange(lon, lat, radiusKm, sizeKm);
        List<Object> res = new ArrayList<>();
        for (String t : tiles) {
            res.add(tpl.opsForGeo().radius("geo:shop:tile:" + t, new Point(lon, lat), new Distance(radiusKm, Metrics.KILOMETERS)));
        }
        return res; // 聚合的多分区结果,调用方可再按距离/评分做二次排序与去重
    }
}

通俗说明

  • 添加门店坐标后,可按当前位置与半径搜索附近结果。
  • 距离单位需统一(米/公里),避免显示误差。
详细解释
  • 添加用 GEOADD,查询用 GEORADIUS 或 Spring 对应操作。
  • 单位统一为米或公里;分页与排序按距离或评分组合。
  • 大量点需分区或分层存储,避免单键过大导致慢查询。
速记口诀
  • 口诀:坐标要统一,半径做分页

🎫 会话与 Token

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;

@Service
public class SessionService {
    @Autowired
    RedisTemplate<String, String> tpl; 

    public void issue(String token, String uid) {
        // 发放会话并设置 2 小时 TTL,可按需续期
        tpl.opsForValue().set("session:" + token, uid, Duration.ofHours(2));
    }

    public String validate(String token) {
        // 校验会话并返回用户ID;未命中表示未登录或已过期
        return tpl.opsForValue().get("session:" + token);
    }
}

通俗说明

  • 会话用 token->用户ID 存储并设置过期,校验时直接读取。
  • 支持续期与强制下线,结合 TTL 实现会话管理。
详细解释
  • 发放时写入 session:{token} 并设置过期,续期用刷新 TTL。
  • 强制下线是删除键或缩短 TTL;可记录最近活动时间作为参考。
  • 安全性需结合白名单与加密,避免伪造与窃取。
速记口诀
  • 口诀:发放写入+TTL,续期刷新、下线删除

♻️ 幂等与去重

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.Duration;

@Service
public class IdempotentService {
    @Autowired
    RedisTemplate<String, String> tpl; 

    public boolean once(String reqId) {
        // 首次写入成功即为“未使用”,写入失败表示“已用过”;过期覆盖重试窗口
        Boolean ok = tpl.opsForValue().setIfAbsent("once:" + reqId, "1", Duration.ofMinutes(10));
        return ok != null && ok;
    }
}

通俗说明

  • 幂等就像“一次性票”:同一个请求 ID 只能用一次,防止重复下单或扣费。
  • setIfAbsent 写入成功代表“首次”,失败代表“已经用过”,简单直接。
  • 请求 ID 可以用订单号、消息 ID 或指纹哈希,保证全局唯一。
详细解释
  • 键形如 once:{reqId},过期时间覆盖业务重试窗口即可。
  • 失败即表示重复调用,直接返回上次结果或提示用户。
  • 请求指纹可由入参+时间窗哈希生成,确保全局唯一。
速记口诀
  • 口诀:一次一票,用过即止

📊 API 网关唯一设备估算(HLL)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;

@Service
public class ApiGatewayDeviceStats {
    @Autowired
    RedisTemplate<String, String> tpl; 

    public void record(String day, String deviceId) {
        // 记录设备 ID(近似去重)到当日 HLL
        tpl.opsForHyperLogLog().add("uv:api:gw:" + day, deviceId);
    }

    public long uniqueDevices(String day) {
        // 当日唯一设备近似规模
        Long c = tpl.opsForHyperLogLog().size("uv:api:gw:" + day);
        return c == null ? 0 : c;
    }

    public long mergeWeek(String week, String... days) {
        // 合并一周内的 HLL 并返回近似规模
        return tpl.opsForHyperLogLog().union("uv:api:gw:" + week, days);
    }
}

通俗说明

  • 以设备 ID 为唯一标识,按日记录、按周汇总近似独设备数。
  • 适合流量画像与容量规划,误差可接受,不用于计费或风控精确统计。
详细解释
  • 统一设备标识规范(如硬件 ID/账号关联),避免匿名与重复导致偏差。
  • 合并按寄存器最大值近似去重;月度汇总可用周汇总再并集。
速记口诀
  • 口诀:设备用 HLL,日记周并估规模

🧾 日志采集去重估算(HLL)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;

@Service
public class LogIngestUnique {
    @Autowired
    RedisTemplate<String, String> tpl; 

    public void ingest(String day, String fingerprint) {
        // 记录日志指纹(字段哈希),重复不会增加计数
        tpl.opsForHyperLogLog().add("uv:log:fp:" + day, fingerprint);
    }

    public long uniqueEvents(String day) {
        // 当日唯一事件近似规模
        Long c = tpl.opsForHyperLogLog().size("uv:log:fp:" + day);
        return c == null ? 0 : c;
    }
}

通俗说明

  • 以日志指纹(字段拼接哈希)近似统计唯一事件规模,评估采集质量与重复率。
  • 指纹重复不会增加计数;近似结果用于报表与监控。
详细解释
  • 指纹建议包含关键字段(时间窗、级别、来源、错误码等),提升去重准确度。
  • 精确去重或重放需用队列/数据库实现;HLL 仅承担规模估算。
速记口诀
  • 口诀:指纹做哈希,HLL估规模

🌐 全域用户合并估算(HLL)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.RedisTemplate;

@Service
public class AllRegionUserUnion {
    @Autowired
    RedisTemplate<String, String> tpl; 

    public long unionDaily(String day) {
        // 并集合并各区域日 UV,得到全域近似规模
        return tpl.opsForHyperLogLog().union(
            "uv:user:all:" + day,
            "uv:user:cn:" + day,
            "uv:user:us:" + day,
            "uv:user:eu:" + day
        );
    }
}

通俗说明

  • 跨地域/业务线的用户 ID 做并集合并,得到全域近似规模。
  • 适合全域画像与跨区域容量评估,误差可接受。
详细解释
  • 统一用户 ID 映射,避免跨系统标识不一致导致重复或漏计。
  • 按日并集后可再做周/月度并集,形成层级汇总。

🔍 小结

  • 验证码登录需设置频控与黑名单,避免短信接口被滥用。
  • 点赞去重使用 Set,取消时需保证并发一致性;可结合消息异步同步数据库。
  • 排行榜使用 ZSet,分页查询时控制范围与内存开销。
  • 签到位图适合按天记录,统计连续天数可结合位运算。
  • UV 统计用 HLL 近似去重,误差在可接受范围内换取低内存占用。
  • Geo 适合附近搜索,需配合距离与分页策略。
  • 会话与 Token 要求可续期与踢人策略,避免长期占用。
  • 幂等与去重通过一次性令牌或请求指纹,防止重复提交。
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值