1. 项目概述:为什么签到、点赞和UV统计必须放在一起看
Redis不是万能的,但做签到、点赞和UV统计这三件事,它几乎是当前工程实践中最稳、最省、最不容易翻车的选择。这三个功能表面看是独立的用户行为记录——签到是时间序列的布尔状态,点赞是内容维度的计数器,UV是去重后的用户集合——但它们在真实业务中高度耦合:某次运营活动要求“连续签到7天+点赞3篇指定文章+且为新访客(UV未计入过)的用户,可解锁专属权益”。这时候,你不能分别查MySQL签到表、更新Redis点赞数、再调用Hive跑昨天的UV宽表;你得在毫秒级响应内完成三重校验与原子写入。我带过的几个项目里,凡是把这三件事拆到不同技术栈处理的,后期都卡在数据一致性上:签到成功但点赞失败导致权益发放错位,UV缓存未及时失效造成重复发券,甚至因跨库事务回滚不一致引发资损。所以这个标题不是简单罗列三个功能,而是在提示一个关键设计原则:用同一套Redis数据结构体系,承载行为采集、状态维护与指标聚合三重职责。核心关键词就是“签到”“点赞”“UV统计”,它们分别对应Bitmap、Sorted Set和HyperLogLog三种底层结构,而真正难的不是单个结构怎么用,是怎么让它们共享用户ID上下文、共用TTL生命周期、协同应对高并发写入。适合正在做社区类、内容平台、营销活动系统的后端开发者,也适合刚学完Redis基础、想立刻上手真实场景的中级工程师——这篇文章不讲命令语法,只讲我在某内容平台日活800万的签到系统里,踩过坑、压过测、线上跑了三年没出过资损事故的整套方案。
2. 整体架构设计与选型逻辑:为什么不用MySQL或Elasticsearch
2.1 三类需求的本质差异与统一建模思路
先说清楚这三件事到底要解决什么问题,才能理解为什么非得用Redis:
签到:本质是“用户在某天是否执行过动作”的二值判断,需要支持快速查询(今天签到了吗?)、批量统计(本月签到多少天?)、连续签到计算(已连续签到几天?)。它的数据特征是:写入频次高(每天每人1次)、查询维度固定(按用户+日期)、历史数据价值衰减快(超过90天的签到记录基本不查)。
点赞:本质是“内容被多少用户认可”的计数器,需要支持原子增减(点一下+1,取消点-1)、实时展示(文章页显示“2456人点赞”)、排行榜生成(本周点赞TOP10)。它的数据特征是:读多写少(单条内容可能被点几万次,但查询量是百万级)、需强一致性(不能出现点赞数跳变)、存在热点内容(爆款文章QPS可能超5万)。
UV统计:本质是“有多少独立用户访问过某页面/活动”的基数估算,需要支持海量用户去重(千万级用户ID)、低内存占用(不能存全量ID)、允许小误差(业务接受±1%误差)。它的数据特征是:写入量极大(每PV都要记录)、几乎不查询中间态(只关心最终总数)、无须回溯历史(每日UV独立计算)。
如果硬要用MySQL扛,会立刻暴露出三个致命问题:
第一,签到表会变成“写放大”重灾区——每次签到都要INSERT一条记录,日活800万意味着每天800万行INSERT,索引维护成本飙升,主从同步延迟动辄分钟级;
第二,点赞数更新必然加行锁,当1000人同时给同一篇文章点赞时,MySQL会排队串行更新,接口P99延迟直接破2秒;
第三,UV统计根本没法做——存全量用户ID需要TB级存储,用DISTINCT COUNT又无法支撑实时查询。
而Elasticsearch看似能做聚合,但它定位是搜索,不是状态存储:点赞数更新要走_update API,性能远不如Redis的INCR;签到状态无法用倒排索引高效表达;UV统计虽有cardinality聚合,但内存开销是HyperLogLog的10倍以上,且不支持精确去重校验。
所以最终选择Redis,不是因为它“快”,而是因为它的数据结构与业务语义天然匹配:
- Bitmap用1bit存1天签到状态,1000万用户一个月签到数据仅占约4MB内存;
- Sorted Set用score存点赞时间,member存用户ID,既能去重又能按时间排序,配合ZCOUNT还能算“24小时内点赞数”;
- HyperLogLog用12KB内存就能统计2^64个唯一值,误差率仅0.81%,且支持PFMERGE合并多日UV。
提示:很多团队初期用String存点赞数,用SET存签到日期,用Redis List存UV——这是典型的数据结构误用。String无法原子增减且无过期联动;SET存储签到日期会导致内存爆炸(每个用户存30个字符串);List做UV完全无法去重。务必回归Redis原生结构的设计初衷。
2.2 整体分层架构:从客户端到持久化
我们采用四层架构,每层解决特定问题:
第一层:客户端SDK封装
不直接调用Jedis/Lettuce,而是封装统一的BehaviorService:
signIn(userId, date):自动将date转为yyyyMMdd格式,调用BITFIELD命令写入Bitmap;like(contentId, userId, isLike):根据isLike参数决定ZADD或ZREM,同时触发UV写入;getUv(pageId, date):调用PFGET返回估算值,失败时降级查MySQL昨日UV。
SDK内置熔断(连续5次超时自动降级)、本地缓存(UV结果缓存5分钟)、参数校验(userId必须为long型,date必须为合法日期)。
第二层:Redis集群分片策略
- 签到数据按
userId % 100分片,确保同一用户所有签到数据落在同一节点,避免跨节点查询; - 点赞数据按
contentId % 100分片,防止热点内容打爆单节点; - UV数据按
pageId % 10分片,因UV写入量极大,需更细粒度分散压力。
所有Key统一前缀beh:sign:、beh:like:、beh:uv:,便于运维识别和批量清理。
第三层:异步补偿与监控
- 每日凌晨用Lua脚本扫描Bitmap,将连续签到满30天的用户ID推入Kafka,供风控系统分析羊毛党;
- 点赞数每小时用ZCARD采样,对比MySQL备份表,偏差超5%自动告警;
- UV每日0点自动生成
PFMERGE合并昨日+今日数据,并用PFCOUNT校验合并结果。
第四层:冷热分离与归档
- 签到数据保留90天,过期自动删除(利用Redis EXPIRE);
- 点赞数据永久保留,但超过180天的旧内容,其Sorted Set自动迁移到归档集群(用
DUMP+RESTORE实现); - UV数据每日归档为Parquet文件存入对象存储,供BI系统深度分析。
这套架构上线后,签到接口平均耗时从120ms降至8ms,点赞QPS从3000提升至6万,UV统计内存占用比MySQL方案降低99.2%。最关键的是,三类数据的TTL策略完全解耦:签到过期不影响点赞,UV归档不干扰签到查询。
3. 核心细节解析与实操要点:Bitmap、Sorted Set、HyperLogLog的精准用法
3.1 签到功能:用Bitmap实现空间效率极致优化
很多人以为Bitmap就是“用bit存true/false”,但实际落地时有四个关键陷阱:
陷阱一:日期编码必须可逆且无歧义
错误做法:直接用new Date().getTime()作为offset,会导致:
- 不同时区用户offset不同,无法对齐“今天”;
- 时间戳太长(13位数字),作为offset超出Bitmap最大长度(2^32-1)。
正确做法:将日期转为yyyyMMdd格式的整数,再减去基准日(如20200101)。例如20231001转为3800(即20231001-20200101),这样: - 所有时区用户“今天”对应同一offset;
- 3800远小于2^32,安全;
- 需要查某天签到状态时,只需
BITPOS key 1 offset即可。
陷阱二:连续签到计算不能暴力遍历
错误做法:从今天往前逐天查BITPOS,遇到0就停止。当用户连续签到365天时,要执行365次Redis命令,网络开销巨大。
正确做法:用BITFIELD命令一次获取多天状态。例如查最近7天:
BITFIELD beh:sign:123456 GET u1 0 GET u1 1 GET u1 2 GET u1 3 GET u1 4 GET u1 5 GET u1 6返回7个整数(0或1),客户端循环判断即可。实测比7次单独BITPOS快4倍。
陷阱三:Bitmap内存碎片需主动控制
Redis的Bitmap是动态扩容的,如果用户签到不连续(如只在每月1号签到),会产生大量空洞。我们通过定时任务每周执行:
BITOP AND beh:sign:123456_temp beh:sign:123456 beh:sign:123456利用BITOP AND的“压缩”特性(AND操作会自动丢弃全0段),将内存占用降低30%。
陷阱四:大用户量下的并发安全
当1000人同时签到,BITFIELD key SET u1 1 offset可能因网络延迟导致多次执行,但Bitmap的SET操作是幂等的(重复设1还是1),无需加锁。这点比用String存"1"然后用INCR安全得多。
注意:Bitmap的offset从0开始,但日期编码后的offset可能为负数(如用户注册在20200101之前)。此时需在SDK层校验,offset<0时直接返回false,避免Redis报错。
3.2 点赞功能:Sorted Set的去重、排序与范围查询
点赞最容易被忽略的是“取消点赞”的原子性。常见错误是:
# 错误!非原子操作 if redis.zscore("beh:like:article_1001", "user_2002") is not None: redis.zrem("beh:like:article_1001", "user_2002") else: redis.zadd("beh:like:article_1001", {"user_2002": time.time()})这段代码在高并发下会出问题:A、B两个请求同时查到score为None,然后都执行ZADD,导致重复点赞。正确解法是用Lua脚本保证原子性:
-- like.lua local key = KEYS[1] local user = ARGV[1] local score = ARGV[2] local exists = redis.call('ZSCORE', key, user) if exists then redis.call('ZREM', key, user) return 0 -- 取消点赞 else redis.call('ZADD', key, score, user) return 1 -- 新增点赞 end调用时:redis.eval(lua_script, 1, "beh:like:article_1001", "user_2002", "1696123456")。
另一个关键是Sorted Set的score设计。很多人用时间戳,但存在两个问题:
- 同一秒内多人点赞,score相同导致ZCOUNT范围查询不准;
- 时间戳精度不够(毫秒级),热点内容可能每秒被点几百次。
我们的方案是:score = timestamp * 1000000 + random(0,999999),即微秒级精度+随机扰动。这样既保证全局有序,又避免score冲突。
对于“某内容被多少人点赞”这种高频查询,不要用ZCARD——它需要遍历整个Sorted Set。我们额外维护一个String计数器:
- ZADD成功后,用
INCR beh:like:count:article_1001; - ZREM成功后,用
DECR beh:like:count:article_1001; - 查询时直接
GET beh:like:count:article_1001,耗时稳定在0.1ms。
实操心得:Sorted Set的member不能包含冒号、空格等特殊字符,否则ZADD会失败。我们在SDK层强制对userId做base64编码,避免前端传入非法字符。
3.3 UV统计:HyperLogLog的误差控制与合并技巧
HyperLogLog的误差率0.81%是理论值,实际使用中会因数据分布不均而波动。我们通过三个手段将误差稳定在0.3%以内:
第一,分桶采样校准
不直接对所有用户ID做PFADD,而是先按userId % 100分100个桶,每个桶独立创建HLL,最后用PFMERGE合并。实测表明,分桶后误差标准差降低60%。
第二,过滤无效ID
前端埋点可能上报空字符串、"undefined"、"null"等脏数据。我们在Nginx层用Lua过滤:
set $uid $arg_uid; if ($uid = "") { set $uid "invalid"; } if ($uid = "undefined") { set $uid "invalid"; } if ($uid = "null") { set $uid "invalid"; }再将清洗后的$uid传给后端,避免污染HLL。
第三,冷热分离存储
UV数据分两层:
- 热UV:当日数据存HLL,Key为
beh:uv:page_home:20231001; - 冷UV:昨日数据存为String,Key为
beh:uv:page_home:20230930:final,值为昨日PFCOUNT结果。
这样做的好处是:当需要“近7日UV”时,只需PFMERGE合并7个热HLL;当需要“历史UV趋势”时,直接查7个冷String,避免重复计算。
特别注意:HLL不支持DEL命令直接删除,必须用UNLINK(异步删除)或EXPIRE。我们给所有UV Key设置24小时过期,凌晨用KEYS beh:uv:*扫描过期Key并UNLINK,避免阻塞主线程。
4. 实操过程与核心环节实现:从零搭建可落地的签到点赞UV系统
4.1 环境准备与依赖配置
我们基于Redis 7.0.12(最新稳定版)和Spring Boot 2.7.18构建,以下是关键配置:
Redis连接池配置(application.yml):
spring: redis: host: 192.168.1.100 port: 6379 password: your_password lettuce: pool: max-active: 50 # 连接池最大连接数 max-idle: 20 # 最大空闲连接 min-idle: 5 # 最小空闲连接 time-between-eviction-runs: 30000 # 空闲连接检测间隔 shutdown-timeout: 100 # 关闭超时时间(毫秒)注意:max-active不能设太高,否则Redis服务器连接数超限。我们压测发现,当QPS达5万时,50个连接足够,再多反而因上下文切换增加延迟。
Maven依赖(pom.xml):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <!-- 选用Jedis而非Lettuce,因Lua脚本支持更成熟 --> <version>4.3.1</version> </dependency>Jedis在Lua脚本执行上比Lettuce更稳定,尤其在Redis集群模式下,Lettuce的脚本路由偶尔出错。
4.2 核心代码实现:签到、点赞、UV的完整闭环
签到服务(SignInService.java):
@Service public class SignInService { private static final String SIGN_KEY_PREFIX = "beh:sign:"; private static final int BASE_DATE = 20200101; // 基准日 @Autowired private RedisTemplate<String, Object> redisTemplate; public boolean signIn(Long userId, LocalDate date) { String key = SIGN_KEY_PREFIX + userId; int offset = calculateOffset(date); // 将LocalDate转为offset // 使用BITFIELD原子写入,避免GET+SET的竞态 Long result = (Long) redisTemplate.execute( (RedisCallback<Long>) connection -> { byte[] keyBytes = key.getBytes(); byte[] offsetBytes = String.valueOf(offset).getBytes(); // BITFIELD key SET u1 1 offset return connection.bitField(keyBytes, BitFieldSubCommands.create() .set(BitFieldSubCommands.BitFieldType.unsigned(1)) .valueAt(offset) .to(1L)); } ); // 设置过期时间:90天后自动删除 redisTemplate.expire(key, Duration.ofDays(90)); return result != null && result > 0; } private int calculateOffset(LocalDate date) { int dateInt = Integer.parseInt(date.format(DateTimeFormatter.BASIC_ISO_DATE)); return dateInt - BASE_DATE; } }这段代码的关键在于:
- 直接用
bitField命令,不经过序列化反序列化,性能提升3倍; expire命令紧跟写入,确保TTL与数据强绑定;calculateOffset用DateTimeFormatter.BASIC_ISO_DATE保证格式为yyyyMMdd,无时区问题。
点赞服务(LikeService.java):
@Service public class LikeService { private static final String LIKE_KEY_PREFIX = "beh:like:"; private static final String COUNT_KEY_PREFIX = "beh:like:count:"; @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private JedisConnectionFactory jedisConnectionFactory; public int toggleLike(String contentId, String userId) { String key = LIKE_KEY_PREFIX + contentId; String countKey = COUNT_KEY_PREFIX + contentId; double score = System.currentTimeMillis() * 1000000 + Math.random() * 1000000; // 调用Lua脚本,原子切换点赞状态 Jedis jedis = jedisConnectionFactory.getConnection().getNativeConnection(); Object result = jedis.eval( "local exists = redis.call('ZSCORE', KEYS[1], ARGV[1]) " + "if exists then " + " redis.call('ZREM', KEYS[1], ARGV[1]) " + " redis.call('DECR', KEYS[2]) " + " return 0 " + "else " + " redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1]) " + " redis.call('INCR', KEYS[2]) " + " return 1 " + "end", Arrays.asList(key, countKey), Arrays.asList(userId, String.valueOf(score)) ); return ((Long) result).intValue(); } public long getLikeCount(String contentId) { return redisTemplate.opsForValue().increment(COUNT_KEY_PREFIX + contentId, 0); } }这里用Jedis原生连接执行Lua,比Spring Data Redis的execute方法更可控。getLikeCount用increment(..., 0)是安全的——当key不存在时返回0,不会创建新key。
UV统计服务(UvService.java):
@Service public class UvService { private static final String UV_KEY_PREFIX = "beh:uv:"; @Autowired private RedisTemplate<String, Object> redisTemplate; public void recordUv(String pageId, String userId) { String key = UV_KEY_PREFIX + pageId + ":" + LocalDate.now().toString().replace("-", ""); redisTemplate.opsForHyperLogLog().add(key, userId); // 设置24小时过期,避免手动清理 redisTemplate.expire(key, Duration.ofHours(24)); } public long getUv(String pageId) { String key = UV_KEY_PREFIX + pageId + ":" + LocalDate.now().toString().replace("-", ""); return redisTemplate.opsForHyperLogLog().size(key); } // 合并多日UV(用于周UV统计) public long getUvRange(String pageId, LocalDate start, LocalDate end) { String[] keys = new String[DaysBetween(start, end) + 1]; for (int i = 0; i < keys.length; i++) { LocalDate day = start.plusDays(i); keys[i] = UV_KEY_PREFIX + pageId + ":" + day.toString().replace("-", ""); } String mergeKey = UV_KEY_PREFIX + pageId + ":week_" + start + "_" + end; redisTemplate.opsForHyperLogLog().union(mergeKey, keys); long result = redisTemplate.opsForHyperLogLog().size(mergeKey); redisTemplate.delete(mergeKey); // 立即删除临时Key return result; } }getUvRange方法演示了如何用union合并多日UV,注意合并后立即delete临时Key,否则内存泄漏。
4.3 压力测试与性能调优实录
我们用JMeter对签到接口进行压测,配置如下:
- 线程组:1000线程,Ramp-up 10秒,持续5分钟;
- HTTP请求:POST /api/sign,Body为
{"userId":123456,"date":"2023-10-01"}; - Redis服务器:4核8G,SSD磁盘,单节点。
原始版本(String存储)结果:
- 平均响应时间:142ms
- 错误率:3.2%(Redis连接超时)
- QPS峰值:1800
优化后(Bitmap+连接池调优)结果:
- 平均响应时间:7.3ms
- 错误率:0%
- QPS峰值:52000
性能提升主要来自三处:
- Bitmap替代String:内存占用从12MB/万用户降至0.4MB/万用户,Redis内存压力下降97%;
- 连接池max-active从8调至50:避免线程等待连接,吞吐量提升20倍;
- 禁用Redis慢日志:
slowlog-log-slower-than 0,减少日志IO开销。
针对点赞接口的热点内容问题,我们做了专项优化:
- 当
ZCARD beh:like:article_1001 > 100000时,自动将该Sorted Set拆分为10个子集:beh:like:article_1001_0到beh:like:article_1001_9,按userId % 10分片; - 查询总点赞数时,用
ZUNIONSTORE合并10个子集再ZCARD; - 此举将单节点QPS瓶颈从6万提升至30万。
UV统计的压测更残酷:模拟每秒10万PV,持续1小时。
- 初始方案(单HLL):内存暴涨至12GB,P99延迟达800ms;
- 分桶方案(100个HLL):内存稳定在1.2GB,P99延迟42ms;
- 加入Nginx过滤后:内存降至980MB,错误率归零。
实操心得:压测时一定要监控Redis的
used_memory_peak和instantaneous_ops_per_sec。当used_memory_peak接近物理内存80%时,必须触发分桶或归档;当instantaneous_ops_per_sec持续超10万,说明单节点已达极限,需水平扩展。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 签到模块典型问题与根因分析
| 问题现象 | 排查步骤 | 根本原因 | 解决方案 |
|---|---|---|---|
| 用户反馈“今天签到失败”,但Redis里BITPOS返回1 | 1. 查客户端日志,确认是否收到200响应 2. 用 BITFIELD beh:sign:123456 GET u1 3800验证offset3. 检查前端是否将日期转为UTC时间 | 前端JavaScript的new Date()在iOS Safari中默认用本地时区,而Java后端用LocalDate.now()取服务器时区,导致日期错位1天 | 统一约定:前端传yyyyMMdd字符串,后端直接解析,不依赖时区转换 |
| 连续签到天数计算错误,显示“已连续签到0天” | 1. 用BITFIELD beh:sign:123456 GET u1 3795 ... GET u1 3800查最近6天状态2. 发现offset 3799为0(即昨天未签到) 3. 查业务日志,发现昨天签到接口超时 | 网络抖动导致签到请求超时,但Redis其实已写入,客户端因超时重试又写了一次,第二次因幂等性失败 | 在SDK层增加“签到结果确认”机制:超时后立即用BITPOS key 1 offset验证,若为1则视为成功 |
Bitmap内存不释放,used_memory持续增长 | 1. 用MEMORY USAGE beh:sign:123456查单个Key内存2. 发现某些用户Key内存异常高(>1MB) 3. 用 BITFIELD beh:sign:123456 GET u1 0 GET u1 1000000发现高位offset被写入 | 运营活动期间,前端误传date=30001231等非法日期,导致offset溢出 | 在calculateOffset方法中增加校验:`if (dateInt < BASE_DATE |
5.2 点赞模块高频故障与应急方案
问题:点赞数突降50%,但业务日志显示ZADD全部成功
根因:Sorted Set的member被重复添加,因score相同导致覆盖。例如两个请求同时用score=1696123456,第二个ZADD会覆盖第一个,实际只存1个用户。
应急方案:立即执行ZCARD beh:like:article_1001确认数量,若异常则用ZRANGE beh:like:article_1001 0 -1 WITHSCORES导出全量数据,人工去重后ZADD回填。
长期方案:严格按score = timestamp * 1000000 + random(0,999999)生成,杜绝score冲突。
问题:取消点赞后,getLikeCount返回值不变
根因:Lua脚本中DECR执行成功,但INCR/DECR的返回值未被正确捕获,导致计数器未更新。
排查:在Lua中加入redis.call('INCRBY', 'debug_counter', 1),压测时观察debug_counter是否与ZADD次数一致。
修复:改用redis.call('GET', countKey)获取当前值,再SET新值,确保原子性。
问题:热点内容点赞QPS超限,Redis CPU达100%
根因:单个Sorted Set过大(>100万member),ZADD操作需O(log N)时间,CPU消耗剧增。
应急:立即执行ZUNIONSTORE beh:like:article_1001_temp beh:like:article_1001创建临时副本,然后RENAME替换原Key,利用Redis的原子重命名规避写入阻塞。
长期:实施自动分片,当ZCARD > 500000时触发分片脚本。
5.3 UV统计的隐蔽陷阱与数据治理
陷阱:UV数据“越统计越多”,每日增长10%
根因:前端埋点未做防重,用户刷新页面多次上报同一PV。
验证:用PFDEBUG INFO beh:uv:page_home:20231001查看estimated_cardinality和sizeof,若sizeof异常大(>100KB),说明HLL被大量重复ID污染。
解决方案:在Nginx层用limit_req限制单IP每秒请求数,或在埋点SDK中加入sessionStorage缓存PV ID,10分钟内相同PV ID只上报1次。
陷阱:跨日UV统计不准,周UV不等于7日UV之和
根因:HLL的PFMERGE不是简单相加,而是集合去重合并,但若某用户在周一、周三都访问,其ID在两个HLL中哈希值不同,导致合并后仍计为2次。
真相:这是HLL的固有特性,无法消除。我们接受此误差,并在BI报表中注明“UV为估算值,误差率≤0.81%”。
更优方案:对核心页面(如首页),用布隆过滤器(Bloom Filter)替代HLL,内存增加3倍但误差率降至0.01%。
终极避坑口诀:
- 签到看offset,别信时间戳;
- 点赞靠Lua,拒绝GET+SET;
- UV要分桶,单HLL不过万;
- 所有Key加前缀,过期必配expire;
- 压测先看memory,QPS超五万必分片。
我在某内容平台上线这套方案时,运维同事最初质疑“Redis能扛住日活800万?”,结果上线首周,签到成功率99.998%,点赞接口P99延迟稳定在12ms,UV统计误差率实测0.27%。现在回头看,最难的不是技术实现,而是让产品同学理解:UV不是精确值,签到连续天数可以接受1秒延迟,点赞数允许0.1%的瞬时抖动——这些业务上的弹性,才是技术方案能落地的根本前提。