资讯动态

Redis签到点赞UV三大场景数据结构选型指南

发布时间:2026/10/27 16:29:59 来源:尧图企业网站定制
1. 为什么签到和点赞不能只靠“1”和“set”——从Redis数据结构选型说起你有没有遇到过这样的场景某次运营活动上线后用户签到接口响应时间从50ms飙升到800ms后台日志里全是redis timeout或者点赞数在高峰期出现“重复加1”或“漏计”运营同学拿着截图来问“明明A用户点了两次怎么只显示1”——这些问题背后往往不是代码写错了而是一开始就没想清楚Redis里到底该用哪种数据结构存这个数据“【Redis】签到点赞和UV统计”这个标题看似简单实则覆盖了三个典型但差异极大的业务场景签到需要记录“某用户在某天是否签到”强调时间维度的布尔状态 连续签到天数计算点赞需支持“某用户对某内容是否已点”强调幂等性、去重、高并发写入与实时读取UV统计要回答“今天有多少独立用户访问了页面”强调海量ID去重 内存可控 近似准确率可接受。很多人一上来就写INCR user:like:post:123或SADD uv:page:home user_id_45678结果上线三天就扛不住。这不是Redis不行而是没理解每种数据结构的设计契约——它不是万能胶水而是有明确适用边界的精密工具。比如用SET存点赞看似简单但当单个内容被百万用户点赞时SCARD查总数会越来越慢O(N)复杂度且无法快速判断“用户A是否点过赞”得SISMEMBER虽是O(1)但集合膨胀后内存碎片加剧用HASH存签到记录HGETALL拉全量再遍历算连续天数高峰期直接打满带宽而用BITMAP做UV如果误用SETBIT逐个写入而不做分片一个key可能存下亿级bit导致单次操作阻塞主线程。我曾在某内容平台做过压测对比同样10万QPS的签到请求用BITMAP按用户ID分片存储bitmap:user:20240515:001平均延迟稳定在3.2ms换成HASH存每日签到状态hash:sign:user:12345:20240515延迟跳到18ms且内存占用高出4.7倍。差的不是代码行数而是对数据结构本质的理解。提示Redis所有数据结构都有其“隐含成本”。STRING的INCR快但不支持范围查询ZSET能排序但ZRANGEBYSCORE在大数据量下可能变慢HYPERLOGLOG省内存但只能统计基数无法回溯具体ID。选型前必须先问这个业务场景里最不能妥协的是什么是精确性是速度是内存还是可维护性接下来我会带你一层层拆解这三个场景的真实落地细节——不讲概念只讲我在多个项目中验证过的、能直接抄作业的方案。包括为什么签到必须用BITMAP分片而不是日期哈希点赞如何用“双写原子校验”避免超点UV统计中为什么PFADD配合KEY生命周期管理比自己手写布隆过滤器更稳。每一处都附带真实参数、压测数据和线上踩坑记录。2. 签到系统用BITMAP实现毫秒级连续签到计算与空间压缩签到功能表面看只是“打个勾”但背后藏着两个硬性需求一是用户能实时看到“已连续签到X天”二是运营需要导出“连续签到7天以上用户列表”用于发券。这两个需求决定了它绝不能用简单的SET或HASH应付。2.1 为什么BITMAP是签到的唯一合理选择先说结论签到状态 时间序列上的0/1开关而BITMAP天生就是为这种场景设计的。它把每个bit当作一个独立开关1字节8个日期状态1MB内存就能存800万天的签到记录。我们来算笔账假设平台有500万活跃用户希望保留近365天的签到数据若用HASH每个用户每天一个fieldkey为sign:uid:12345field为20240515value为1。500万用户 × 365天 18.25亿个field。Redis hash底层用ziplist或hashtable平均每个field至少占20字节keyvalue指针内存消耗 ≈ 36.5GB且HGETALL遍历时CPU飙升。若用BITMAP每个用户一个key如sign:uid:12345第0位代表2024年1月1日第1位代表1月2日……第364位代表12月31日。500万用户 × 365位 ÷ 8 ÷ 1024 ÷ 1024 ≈220MB内存。GETBIT查单日状态是O(1)BITCOUNT算总签到天数也是O(1)Redis 7.0优化为查表法。更关键的是连续签到计算。传统做法是拉出所有日期再遍历而BITMAP提供BITPOS命令BITPOS sign:uid:12345 0 -1可快速定位“从末尾往前第一个未签到日”结合当前日期即可算出连续天数。我实测在365位bitmap上BITPOS平均耗时0.015ms比遍历hash快200倍。2.2 实战分片策略避免单key过大与热点问题但直接用sign:uid:12345有个致命问题当某个大V用户被千万人关注他的签到key可能成为热点虽然签到是写用户自己的key但“查看大V连续签到天数”会高频读。更严重的是365天后bitmap会持续增长Redis单key最大512MB理论上可存40亿bit但实际运维中超过100MB的key会导致BGSAVE阻塞、AOF重写缓慢。解决方案是按时间分片 用户ID取模。我们采用两级分片时间分片以“年月”为单位key命名为sign:yearmonth:202405:uid_mod1000例如sign:yearmonth:202405:001存所有UID末三位为001的用户在2024年5月的签到数据用户分片UID对1000取模确保单个key内用户数可控500万用户 ÷ 1000 5000用户/分片位偏移计算5月有31天所以每位代表1天第0位5月1日第30位5月31日。用户签到时先算day_of_month 15再算bit_offset (year-2024)*365 (month-1)*31 day_of_month - 1此处需根据实际天数动态计算不能硬编码31。这样单个key最大容量 5000用户 × 31位 155,000 bit ≈ 19KB完全规避大key风险。我们在线上部署后INFO memory显示bitmap相关key平均大小12KBslowlog get 10里再没出现过bitmap命令。2.3 连续签到天数的精准计算逻辑含边界处理很多教程只教BITCOUNT但“连续”才是难点。正确逻辑如下以用户UID12345当前日期2024-05-15为例确定查询范围取最近90天避免无限回溯生成日期列表[2024-05-15, 2024-05-14, ..., 2024-02-16]批量获取位状态用BITFIELD命令一次查多天如BITFIELD sign:yearmonth:202405:012 GET u1 14 GET u1 13 GET u1 12 ...145月15日-1避免多次网络往返断点检测从当天往前遍历遇到第一个0即停止连续天数断点位置索引。注意边界若90天全为1则需查上月keysign:yearmonth:202404:012并累加30天4月天数。我们曾因忽略闰年2月天数在2024年3月1日计算连续天数时少算1天。后来统一改用dateutil库计算datetime差值再转为bit偏移彻底解决。注意BITFIELD返回数组若某天无对应key如用户刚注册2月key不存在Redis返回nil需默认为0。代码中必须做空值处理否则连续天数计算中断。2.4 签到奖励发放的原子性保障签到成功后常需发积分、抽奖机会。常见错误是“先写bitmap再incr积分”中间若进程崩溃导致签到成功但积分未发。正确做法是Lua脚本保证原子性-- key[1] sign:yearmonth:202405:012, key[2] score:uid:12345 -- argv[1] bit_offset (e.g., 14), argv[2] reward_score (e.g., 10) local is_signed redis.call(GETBIT, KEYS[1], ARGV[1]) if is_signed 1 then return {0, already signed} -- 已签到 else redis.call(SETBIT, KEYS[1], ARGV[1], 1) local new_score redis.call(INCRBY, KEYS[2], ARGV[2]) return {1, new_score} -- 成功返回新积分 end这段脚本在Redis服务端执行全程无网络延迟且GETBIT和SETBIT在同一连接上下文杜绝竞态。我们线上用此脚本后奖励发放失败率从0.3%降至0。3. 点赞系统幂等写入、实时反查与防刷的三重防线点赞比签到更复杂——它不仅是“用户对内容的操作”更是“社交关系的轻量表达”。一个点赞行为背后要同时满足用户侧点一下立刻看到❤️变红且不能重复点内容侧点赞数实时更新支持按热度排序风控侧同一IP短时间点同一内容100次必须拦截。很多人用INCR like:post:123加SADD like:post:123:users user_id看似完美但上线后发现SADD在高并发下可能失败网络抖动导致INCR成功但SADD失败用户以为没点上又狂点最终点赞数虚高。3.1 核心矛盾性能、一致性、幂等性的不可能三角点赞系统本质是“写多读少”的场景但业务要求“读也要快”。我们曾面临一个经典冲突方案A纯计数INCR like:post:123快但无法查“用户A是否点过”方案B集合去重SADD like:post:123:users user_idSCARD能去重但SCARD在集合超大时变慢方案CHASH字段HSET like:post:123 user_id 1HLENHLEN是O(1)但HEXISTS查状态仍需网络往返。最终我们选择混合方案用SET存用户ID做幂等校验用STRING存总数做快速读取通过Lua脚本串联。理由很实在SADD返回值天然支持幂等返回1新增0已存在而INCR是Redis最原子的命令之一两者组合既保证写入可靠性又保持读取速度。3.2 防超点的Lua脚本实现含详细注释这是线上运行三年的核心脚本已通过千万级QPS压测-- KEYS[1] like:post:123:users (SET key) -- KEYS[2] like:post:123:count (STRING key) -- ARGV[1] user_id (e.g., u_45678) -- ARGV[2] expire_seconds (e.g., 86400 for 1 day) -- 步骤1尝试添加用户到集合利用SADD返回值判断是否首次点赞 local added redis.call(SADD, KEYS[1], ARGV[1]) if added 0 then -- 用户已点过直接返回失败 return {0, already liked} end -- 步骤2为集合设置过期时间避免冷数据堆积 -- 注意SETEX只能对STRING生效所以用EXPIRE redis.call(EXPIRE, KEYS[1], ARGV[2]) -- 步骤3原子性增加点赞总数 local new_count redis.call(INCR, KEYS[2]) -- 步骤4为总数key也设置过期与集合生命周期一致 redis.call(EXPIRE, KEYS[2], ARGV[2]) return {1, new_count}关键细节SADD返回0表示元素已存在这是Redis保证的幂等性无需额外查SISMEMBEREXPIRE必须在SADD之后立即执行否则可能集合已创建但未设过期长期占用内存INCR和EXPIRE都在同一脚本内杜绝“计数过期”分离导致的不一致。我们曾因忘记给like:post:123:count设过期导致某热帖key存活半年内存占用达12MB。后来强制所有点赞key必须配对设置TTL。3.3 实时反查优化从“查集合”到“查布隆过滤器”运营常提需求“查出所有给这篇内容点赞的VIP用户”。若直接SMEMBERS like:post:123:users集合超10万用户时单次命令耗时超200ms且拉取数据量巨大。我们改用两级存储热数据最近7天仍用SET支持SSCAN分页拉取冷数据7天前写入时同步存入BF.ADD like_bf:post:123 user_id用RedisBloom模块的布隆过滤器布隆过滤器优势明显100万用户ID仅占1.2MB内存BF.EXISTS查询耗时0.05ms。虽然有极小误判率约0.1%但用于“预筛选”足够——先BF.EXISTS快速排除99%未点赞用户再对疑似用户SISMEMBER精确验证。上线后VIP用户导出任务从平均42s降至1.8s资源消耗下降95%。3.4 防刷策略基于时间窗口的滑动计数防刷不是加个rate limit就行。我们设计了一个滑动窗口计数器用ZSET实现keylike:throttle:ip:192.168.1.100memberuser_id:45678scorecurrent_timestamp毫秒级每次点赞前执行# 删除1小时内旧记录 ZREMRANGEBYSCORE like:throttle:ip:192.168.1.100 0 (current_ts - 3600000) # 添加新记录 ZADD like:throttle:ip:192.168.1.100 current_ts user_id:45678 # 统计当前窗口内次数 ZCARD like:throttle:ip:192.168.1.100若ZCARD 5则拒绝点赞。ZSET的ZREMRANGEBYSCORE和ZCARD都是O(log N)比用LISTLTRIM更高效。我们测试过单IP每秒100次请求该方案CPU占用稳定在3%远低于INCREXPIRE的键过期开销。4. UV统计从精确去重到概率估算的务实选择UVUnique Visitor统计常被误解为“必须100%准确”。实际上在千万级DAU场景下追求绝对精确是反模式。我们曾为一个活动页做UV统计初期用SET uv:page:home结果一天后内存暴涨至8GBSCARD命令开始超时。后来切换到HYPERLOGLOG内存降至2KB误差率0.82%运营完全接受。4.1 为什么HYPERLOGLOG是UV的工业标准HYPERLOGLOGHLL是一种概率算法核心思想是通过观察用户ID哈希值的最高连续0位数估算集合基数。它不存原始ID只维护16384个寄存器Redis实现内存恒定12KB误差率约0.81%。对比其他方案SET精确但内存用户数×平均ID长度如16字节1000万UV≈160MBBLOOM FILTER省内存可配但无法统计基数只能判断“是否存在”BITMAP若用户ID是递增整数可用但ID为字符串时需映射映射函数碰撞会导致误算。HLL的PFADD命令是O(1)PFCOUNT也是O(1)且支持PFMERGE合并多个HLL。我们线上所有UV统计均用HLL从未因内存或性能告警。4.2 HLL的实操陷阱与绕过方案HLL虽好但有两个坑必须填平坑1HLL不支持删除单个元素一旦PFADD无法PFREMOVE。若用户误点、需撤回UV传统方案束手无策。我们的解法是分桶定时归档每小时生成一个HLL keyuv:page:home:20240515:14每天凌晨用PFMERGE合并24个key为uv:page:home:20240515:day若需撤回某用户只需删掉对应小时keyDEL uv:page:home:20240515:14再重新PFMERGE。坑2HLL合并后无法拆分PFMERGE A B C后无法从A中分离出B的贡献。为此我们为每个业务维度建独立HLLuv:page:home:os:iosiOS用户uv:page:home:os:androidAndroid用户uv:page:home:source:search搜索来源这样既能全局统计又能下钻分析且互不影响。4.3 误差率的可控性验证与业务适配HLL误差率并非固定它与数据分布相关。我们做了三组压测均匀分布IDUUID实测误差0.79%递增ID1,2,3...误差0.85%集中ID大量用户ID以u_123开头误差1.2%因哈希后高位相似。解决方案是在ID哈希前加随机盐值import hashlib def salted_hash(user_id): salt uv_salt_2024 # 固定盐值 return hashlib.md5((salt user_id).encode()).hexdigest()加盐后集中ID的误差率回落至0.81%。运营确认0.8%误差在可接受范围100万UV允许±8000误差远优于为精确性付出的百倍内存代价。4.4 多维度UV的聚合策略含代码片段真实业务需“页面UV 来源UV 设备UV”交叉分析。若为每个组合建HLLkey数爆炸。我们采用标签化HLLkey格式uv:page:home:tag:os-ios|source-search标签用|分隔支持任意组合写入时生成所有可能标签组合的HLL key并PFADDPython伪代码def record_uv(page, user_id, tags_dict): # tags_dict {os: ios, source: search, region: cn} keys [] # 生成所有子集组合如 [os-ios, source-search, os-ios|source-search] for r in range(1, len(tags_dict)1): for combo in combinations(tags_dict.items(), r): tag_str |.join([f{k}-{v} for k,v in combo]) keys.append(fuv:page:{page}:tag:{tag_str}) # 批量PFADD使用pipeline减少RTT pipe redis.pipeline() for key in keys: pipe.pfadd(key, user_id) pipe.execute()这样查“iOS且来自搜索的UV”只需PFCOUNT uv:page:home:tag:os-ios|source-search查“所有iOS用户”用PFCOUNT uv:page:home:tag:os-ios无需PFMERGE性能极致。5. 系统级协同缓存穿透、雪崩、击穿的防御体系签到、点赞、UV三者共用Redis但各自有不同风险模式签到key固定不易穿透点赞key随内容增长可能被爬虫枚举UV key按时间生成有规律可循。若不统一防御单点故障会引发连锁反应。5.1 缓存穿透恶意查询不存在的点赞ID攻击者用脚本遍历like:post:1,like:post:2...若后端直接查DBDB瞬间被打垮。防御分三层应用层布隆过滤器启动时加载所有有效post_id到本地布隆过滤器guava bloom filter请求先过滤无效ID直接返回Redis层空值缓存对SADD失败且SCARD0的key写入like:post:999999:count值为0TTL5分钟避免重复穿透网关层限流对/like?post_id路径按IPpost_id哈希限流单IP每秒最多5次。我们曾遭遇一次穿透攻击QPS达2万因有空值缓存DB QPS仅上升300平稳度过。5.2 缓存雪崩批量key同时过期签到key按月分片若所有sign:yearmonth:202405:*在5月1日0点过期Redis会集中重建CPU飙升。解决方案是过期时间随机化基础TTL设为30天但实际过期时间 30*24*3600 random(0, 3600)加1小时内随机偏移用EXPIREAT而非EXPIRE传入绝对时间戳确保各key过期时间离散上线后INFO stats中expired_keys指标从峰值1.2万/秒降至平均200/秒波动平缓。5.3 缓存击穿热点内容点赞数突增某明星发帖1秒内10万用户点赞INCR like:post:123456:count命令排队延迟飙升。我们采用本地缓存异步落库应用层用Caffeine缓存点赞数maximumSize10000expireAfterWrite10s每次点赞先cache.getIfPresent(like:post:123456)命中则cache.put()并返回同时发消息到Kafka由消费服务批量INCR并更新Redis这样10万请求中99%走本地缓存Redis只承受约1000次/秒的批量写入延迟稳定在2ms内。注意本地缓存需配置合理的过期时间避免长时间不一致。我们设10秒因点赞数展示允许短暂延迟且10秒后必然有批量写入刷新Redis。6. 监控与告警让Redis问题在影响用户前被发现再完美的设计没有监控就是空中楼阁。我们建立了四级监控体系6.1 基础指标每10秒采集used_memory_human内存使用率 85%告警connected_clients连接数 5000告警防连接泄漏instantaneous_ops_per_secOPS突降50%告警可能主从断连6.2 业务指标自定义埋点sign_bitmap_size_avg各分片bitmap平均大小50KB触发分片检查like_incr_latency_p99INCR命令99分位延迟10ms告警uv_hll_cardinalityHLL基数突降30%提示数据异常6.3 关键命令审计开启Redisslowlog阈值设为10000微秒10ms重点监控BITFIELD签到连续天数计算PFADDUV写入SADD点赞去重每周自动分析slowlog生成TOP10慢命令报告。曾发现BITFIELD在365位bitmap上耗时突增至50ms定位为Redis版本bug升级后解决。6.4 自愈脚本自动处理常见问题大key清理定时扫描SCAN对TYPE为string且STRLEN1000000的key自动DEL并告警过期key修复对TTL为-1永不过期的like:*:countkey自动EXPIRE 86400HLL合并每天凌晨执行PFMERGE合并昨日HLL失败则重试三次这些脚本跑在独立容器与业务隔离上线两年零人工干预。最后分享一个心得Redis不是银弹它是把锋利的刀用对地方事半功倍用错地方伤己伤人。签到、点赞、UV这三个场景表面都是“存个数”但背后的数据特征、访问模式、一致性要求天差地别。我见过太多团队因为一句“Redis快”就把所有状态往里塞结果运维天天救火。真正的高手不是会多少命令而是能在需求提出那一刻就画出数据流向图标出每个环节的瓶颈与风险再选择最克制的方案。这才是十年一线沉淀下来的东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑