
1. 从数人数说起一个被低估的经典问题zz老师数人数这个标题乍一看像是某个班级群里的日常段子但如果你在一线做过几年开发或者带过团队就会立刻意识到——这背后其实是一个非常经典的算法与工程问题在动态变化的人群中如何快速、准确地统计出当前的总人数。这个问题在校园场景里是老师点名在互联网场景里是在线用户统计在物联网场景里是设备在线数在游戏里是房间玩家计数。场景不同但核心逻辑高度一致。我第一次接触这类需求是在做一个校园考勤系统的时候。当时的需求听起来特别简单老师站在教室门口学生陆续进来系统要实时显示当前已到多少人。结果真做起来才发现坑远比想象中多——学生可能中途出去接水、可能重复刷卡、可能两个人挤在一起同时进、可能有人从后门溜进来。每一个可能背后都是一次计数错误。所以数人数这件事本质上不是简单的加法而是一个状态管理问题。这篇文章我会围绕zz老师数人数这个场景把背后的核心思路、数据结构选型、实操实现、常见坑和排查技巧全部拆开讲清楚。不管你是刚学编程想找个练手项目还是已经工作几年想系统梳理一下计数类问题的解法都能从里面拿到可以直接用的东西。我会尽量用大白话把原理讲透同时给出可以直接抄的代码和配置。2. 核心思路拆解为什么数人数没那么简单2.1 三种典型场景与对应的计数模型在动手写代码之前必须先搞清楚你面对的是哪种计数场景。我把常见的分成三类每一类的解法完全不同。第一类是静态快照计数。比如老师上课前看了一眼教室想知道现在坐了多少人。这种最简单遍历一遍数一下就行时间复杂度O(n)没有任何技术含量。但它的局限是只能反映某一瞬间的状态人一动数据就过期了。第二类是增量事件计数。学生进门就加一出门就减一系统维护一个实时总数。这是最常见的模型也是zz老师数人数最可能对应的场景。它的核心难点在于事件可能丢失、可能重复、可能乱序到达。你以为是简单的加减法实际上是在做分布式状态一致性。第三类是去重计数。老师想知道今天一共来了多少个不同的学生同一个学生进进出出多次只算一个。这就涉及到集合去重需要用哈希表或者位图来记录谁已经算过了。数据量小的时候用HashSet就够了数据量上百万的时候就得考虑布隆过滤器或者HyperLogLog这类概率数据结构。我个人的经验是90%的人一开始都会把问题想成第一类写出来发现不对然后改成第二类最后被第三类的去重需求打脸。所以第一步一定是跟需求方确认清楚你要的是瞬时值、累计值还是去重值。这三个词听起来差不多实现起来差十万八千里。2.2 为什么选择计数器事件流而不是定时全量扫描假设一个教室有500个学生如果每秒全量扫描一次来统计人数每次扫描要遍历500条记录一秒钟就是500次操作。听起来不多但如果同时有100个教室呢那就是每秒5万次操作而且大部分扫描结果和上一次是一样的纯属浪费。更聪明的做法是维护一个计数器只在有人进出的时候更新它。这样每次事件只做一次加法或减法复杂度从O(n)降到O(1)。这就是典型的用空间换时间也是绝大多数实时统计系统的标准做法。但这里有个关键前提事件必须可靠。如果学生进门的事件丢了计数器就永远少一个人如果同一个进门事件被处理了两次计数器就多一个人。所以计数器事件流这个方案能不能用取决于你的数据源可不可靠。刷卡机、门禁传感器这类硬件设备通常比较可靠但如果是靠人工点击按钮来触发事件那出错概率就很高了。提示如果你的数据源不可靠宁可退回到定时全量扫描的方案慢一点但至少结果是对的。准确性永远优先于性能。2.3 数据结构选型从int到Redis到HyperLogLog选什么数据结构来存这个计数取决于你的规模和精度要求。我整理了一个对照表方便你快速决策。方案适用规模精度优点缺点内存int变量单机、小规模精确极快、极简重启丢失、无法分布式数据库计数列中小规模精确持久化、易查询并发写有锁竞争Redis INCR中大规模精确原子操作、高性能需额外维护RedisRedis HyperLogLog超大规模去重约0.81%误差内存占用极小不精确、无法减位图Bitmap大规模去重精确内存高效需要ID连续对于zz老师数人数这种校园场景一个班几十个人用内存int或者数据库计数列完全够用。但如果你想把这个系统做成一个能支撑全校甚至多校的平台那就得往Redis方向考虑。我见过太多项目一开始用数据库计数等到并发上来了才发现行锁把整个系统拖垮了。HyperLogLog这个数据结构值得单独说一句。它用极小的内存12KB左右就能估算出上亿级别的去重基数误差在0.81%以内。如果你要统计今天有多少个不同IP访问了系统这种需求它是神器。但它的缺点是只能加不能减而且结果不精确。所以它适合做趋势分析不适合做精确考勤。3. 实操实现从零搭一个可靠的人数统计模块3.1 基础版本单机内存计数器先从最简单的开始。假设你只是想在本地跑一个demo验证一下逻辑那用Python写一个类就够了。class PeopleCounter: def __init__(self): self.count 0 self.seen set() # 用于去重 def enter(self, person_id): if person_id in self.seen: return # 重复进入忽略 self.seen.add(person_id) self.count 1 return self.count def leave(self, person_id): if person_id not in self.seen: return # 没进来过忽略 self.seen.remove(person_id) self.count - 1 return self.count def current(self): return self.count这段代码看起来很简单但里面有两个关键设计。第一enter方法里先检查person_id是否已经在seen集合里如果在就直接返回避免重复计数。第二leave方法里先检查这个人是否真的在集合里如果不在就忽略避免把计数减成负数。这两个检查就是幂等性的体现。所谓幂等就是同一个操作执行一次和执行多次结果是一样的。在分布式系统里事件重复投递是常态没有幂等保护你的计数迟早会乱。注意这个基础版本只适合单机单线程。如果多线程同时调用entercount 1这行代码会有竞态条件导致计数偏小。解决办法是加锁或者用原子操作。3.2 进阶版本Redis原子计数器单机版本最大的问题是重启就丢数据而且没法多台机器共享。这时候Redis就派上用场了。Redis的INCR和DECR命令是原子操作天然适合做计数器。import redis r redis.Redis(hostlocalhost, port6379, db0) def enter(person_id): # 用SETNX做去重key为 person:enter:{id} key fperson:enter:{person_id} if r.setnx(key, 1): r.expire(key, 3600) # 1小时后过期防止内存泄漏 return r.incr(room:count) return int(r.get(room:count) or 0) def leave(person_id): key fperson:enter:{person_id} if r.delete(key): return r.decr(room:count) return int(r.get(room:count) or 0)这里用SETNXSET if Not eXists来实现去重。如果这个人的进入标记已经存在说明是重复事件直接忽略。如果不存在就设置标记并给计数器加一。expire设置过期时间是为了防止长期运行后内存被撑爆——毕竟一个学生不可能在教室里待超过一小时。但这里有个隐患setnx和incr是两条命令中间如果程序崩溃可能出现标记设了但计数没加的情况。要彻底解决这个问题得用Lua脚本把两条命令打包成一个原子操作。-- enter.lua local key KEYS[1] local countKey KEYS[2] if redis.call(SETNX, key, 1) 1 then redis.call(EXPIRE, key, 3600) return redis.call(INCR, countKey) else return redis.call(GET, countKey) end用Lua脚本的好处是Redis保证脚本内的所有命令原子执行不会被打断。这是生产环境的标准做法我强烈建议你在正式项目里用Lua而不是分开调用。3.3 参数计算过期时间到底设多长上面代码里我随手写了3600秒但实际项目中这个值需要认真算。过期时间设太短学生还在教室里标记就过期了出去的时候减不掉计数偏大设太长内存占用高而且如果学生第二天再来昨天的标记还在会被误判为重复。合理的计算方式是过期时间 单次停留最长时间 × 安全系数。假设一节课最长2小时安全系数取1.5那过期时间就是3小时即10800秒。如果场景是全天开放的自习室那可能得设12小时以上。内存占用也要算一笔账。每个标记key大约占100字节含key名、value、过期时间等元数据1万个学生同时在线就是1MB10万个就是10MB。这个量级对Redis来说毫无压力但如果你的场景是百万级设备在线那就得考虑用位图或者分片来优化了。3.4 完整流程一次进出的全链路追踪把上面的东西串起来一次完整的学生进出流程是这样的学生刷卡门禁设备产生一条事件包含person_id、timestamp、direction进/出。事件通过消息队列比如Kafka或者RabbitMQ发送到后端服务。后端服务消费事件根据direction调用enter或leave逻辑。Redis执行Lua脚本原子地更新去重标记和计数器。前端通过WebSocket或者轮询接口获取最新计数展示给老师。这个链路里消息队列的作用是削峰填谷。下课高峰期可能一秒钟有几十个学生同时刷卡如果直接打到Redis虽然Redis扛得住但网络往返次数太多。用队列缓冲一下后端可以批量消费效率更高。我实测下来单台Redis配合Lua脚本每秒处理1万次进出事件毫无压力。瓶颈通常不在Redis而在事件产生端和网络传输。4. 常见问题与排查技巧实录4.1 计数偏大重复事件是头号嫌疑计数偏大是最常见的问题90%的情况是重复事件导致的。可能的原因有门禁设备网络抖动重发、消息队列至少一次投递语义、用户快速连续刷卡两次。排查思路很简单先看日志里同一个person_id在短时间内出现了几次。如果确实有重复那就在消费端做去重。去重可以用Redis的SETNX也可以用本地缓存加时间窗口。我一般推荐用Redis因为多实例部署时本地缓存不共享。还有一种隐蔽的偏大原因是过期时间设太短。学生进去的时候标记设了待了超过过期时间标记自动删了出去的时候delete返回0计数器不减结果就多了一个。这种问题很难发现因为日志看起来一切正常。解决办法是把过期时间设得足够长或者在leave的时候不依赖标记是否存在直接减。4.2 计数偏小事件丢失与并发竞态计数偏小通常有两个原因。一是事件丢失比如消息队列消费失败没有重试或者网络中断导致事件没发出去。二是并发竞态多个线程同时读-改-写计数器后写的覆盖了先写的。事件丢失的排查要看消息队列的消费位点和重试记录。如果发现有消息被跳过那就是消费逻辑有问题。并发竞态则要看是不是用了非原子的操作比如先GET再SET这种写法在多线程下必出问题。提示任何读取-修改-写入的操作只要涉及并发就必须用原子命令或者加锁。Redis的INCR是原子的但GETSET不是。4.3 计数归零或负数重启与异常处理计数器突然变成0多半是Redis重启或者key过期了。如果Redis没开持久化重启后数据全丢计数器自然归零。解决办法是开启AOF持久化或者定期把计数快照存到数据库。计数器变成负数说明leave执行次数多于enter。这通常是因为去重标记过期了但计数器没同步或者有人恶意构造了leave请求。防御方法是给leave加校验只有标记存在时才减减到0就停止。4.4 常见问题速查表现象可能原因排查方法解决方案计数偏大重复事件查日志中同一ID出现次数用SETNX去重计数偏大过期时间太短检查标记TTL与停留时长延长TTL计数偏小事件丢失查消息队列消费位点加消费重试计数偏小并发竞态检查是否用非原子操作改用INCR或Lua计数归零Redis重启检查持久化配置开启AOF计数为负leave多于enter统计进出事件数量加校验减到0停止4.5 独家避坑技巧第一个技巧是给计数器加版本号。每次更新计数器时同时更新一个版本号读取的时候如果发现版本号回退说明数据被重置了可以触发告警。这个技巧在分布式环境里特别有用能帮你快速发现数据异常。第二个技巧是定期对账。每天凌晨低峰期用全量扫描的方式重新数一遍人数和计数器对比。如果差异超过阈值就自动修正计数器并记录日志。这叫最终一致性校验是金融系统里常用的手段用在人数统计上同样有效。第三个技巧是给关键操作加trace_id。一次进出事件从产生到计数更新全链路带上同一个trace_id出问题的时候一搜就能看到完整链路。没有trace_id的话排查起来就像大海捞针。5. 扩展思考从数人数到通用计数框架5.1 多房间场景下的分片计数如果zz老师不只管一个班而是管整个年级甚至整个学校那就需要支持多房间计数。最简单的做法是给每个房间一个独立的计数器key比如room:101:count、room:102:count。查询某个房间就读对应的key查询全校就遍历所有key求和。但遍历所有key在房间数量多的时候会很慢。更好的做法是维护一个汇总计数器每次房间计数变化时同步更新汇总值。不过这样又引入了新的一致性问题——汇总值和各房间值可能对不上。所以定期对账依然是必要的。5.2 从精确计数到概率计数当规模大到一定程度精确计数的成本会变得不可接受。比如你要统计今天有多少个不同的设备连接过WiFi可能有几百万个设备用HashSet存所有设备ID要几百MB内存。这时候HyperLogLog就是救星12KB就能估算出百万级基数误差不到1%。代价是结果不精确而且只能加不能减。所以它适合做今日活跃设备数这种只增不减的统计不适合做实时在线人数。选型的时候一定要看清楚需求是精确还是估算。5.3 计数系统的监控与告警一个成熟的计数系统必须配监控。我一般会监控这几个指标计数器的当前值、每秒进出事件数、去重标记的数量、Lua脚本的执行耗时、对账差异值。任何一个指标异常都触发告警。特别是对账差异值这是发现数据问题的最后一道防线。我建议把差异阈值设成总人数的1%超过就告警。这样即使有小的计数偏差也能在影响扩大之前被发现和修正。5.4 一个容易被忽略的细节时区与跨天如果统计的是今日到校人数跨天的时候需要重置计数器。但重置的时机很讲究——是按自然日零点重置还是按学校的作息时间重置如果按零点重置那凌晨还在教室自习的学生怎么算我的做法是引入一个统计周期的概念周期开始和结束时间可配置。重置的时候不是简单地把计数器清零而是把当前值归档到历史记录然后开一个新的计数器。这样既能保证当天数据准确又能保留历史数据用于分析。时区问题也要注意。如果系统部署在云服务器上服务器时区可能是UTC而学校用的是本地时区。跨天重置的时候如果不做时区转换可能会在错误的时间重置。这个坑我踩过凌晨三点被叫起来修数据记忆深刻。6. 写在最后的一点个人体会做数人数这个项目最大的感受是越是看起来简单的问题越容易在细节上翻车。一个加法运算背后牵扯出去重、幂等、并发、持久化、对账、监控一整条链路。这也是为什么我常说能把一个计数器做对的人基本就能做好大部分后端系统了。如果你正在做类似的项目我的建议是先把最简单的版本跑通然后逐步加可靠性保障。不要一上来就上分布式、上消息队列、上Lua脚本那样容易把自己绕晕。先用内存计数器验证逻辑再换Redis解决持久化和共享问题最后加对账和监控解决数据可信问题。每一步都跑通了再往下走比一次性堆一堆技术栈要稳得多。另外跟需求方确认清楚你要的到底是什么数这件事怎么强调都不过分。瞬时值、累计值、去重值这三个词在需求文档里经常混着用但实现完全不同。我见过太多项目因为一开始没对齐需求做到一半推倒重来的。花半小时把需求问清楚能省你半个月的返工。