接着高峰在线人数那篇的一个后续问题:如果没有真实的事件时间字段,直接拿"日志写入时间"当成登录/登出时间来算,行不行?答案不是简单的行或不行——真实实验证明,关键在于登录和登出这两条日志的写入延迟是不是对称的。
200 个用户"本该"按 0,1,2...199 的顺序登录,但每个登录请求的处理都会有一点点不确定的延迟(排队、加锁、调度)——用真实的 Python 多线程模拟:
def handle_login(uid, event_time):
time.sleep(random.uniform(0, 0.005)) # 模拟处理延迟抖动
with log_lock:
log_lines.append((uid, event_time)) # 真正写入日志的时刻
# 200 个线程并发调用 handle_login(uid, uid),uid 本身代表"事件本该发生的顺序"
物理写入顺序前20个: [13, 9, 20, 19, 26, 0, 8, 35, 56, 3, 16, 24, 42, 27, 30, 39, 31, 32, 71, 72]
按事件时间排序后前20个: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
物理顺序 == 时间顺序: False
逆序对数量: 3503 / 总记录数 200
200 条记录里有 3503 对是逆序的——只要"事件发生"和"写入日志"这两个动作之间隔着不确定的处理延迟,物理写入顺序就没法保证等于事件真正发生的顺序。这跟并发转账死锁是同一类问题:并发环境下,任何"先发生先记录"的假设都需要真实验证,不能凭直觉认为理所当然。
30 万条真实模拟记录,分别测三种"用日志时间代替事件时间"的情形:
真实峰值(ground truth): 110460 # 场景A: 登录登出的写入延迟都是 -0.5~+0.5 秒的对称随机抖动 # 场景B: 登录几乎实时写入,登出走异步清理,统一晚 2.5~3.5 秒才写日志 # 场景C: 每 500ms 批量 flush 一次日志对称随机抖动后的峰值: 110463 (误差: 3) 登出系统性延迟3秒后的峰值: 110475 (误差: 15) 批量flush(500ms)后的峰值: 110460 (误差: 0)
对称抖动和批量 flush 的误差都小到可以忽略——抖动是随机的,登录早一点、登出也早一点,大体上互相抵消;批量 flush 只是把时间"取整"到固定刻度,不会引入方向性偏差。但登出系统性延迟 3 秒,峰值稳定地偏高了 15——这不是随机噪声,是一个固定方向的偏差,不会因为数据量大就被平均抵消。
上面用的会话时长动辄几万秒,几秒钟的延迟自然显得微不足道。换成更真实的场景:一次登录潮里,8000 个用户在 5 分钟内集中登录,会话时长 1~10 分钟:
真实峰值(会话1~10分钟): 6603 logged_records = [(uid, lo, hi + DELAY) for uid, lo, hi in real_records] # 登出延迟 DELAY 秒才写日志登出延迟3秒写日志后的峰值: 6645 (误差 +42, +0.64%) 登出延迟30秒写日志后的峰值: 6959 (误差 +356, +5.39%)
延迟越大,误差越大,而且方向永远是正的——"登出写晚了"意味着系统会误以为这些用户还在线,多算一段时长,这是一个稳定的正向偏差,不是能被大数据量平均掉的随机噪声。会话越短(相对延迟占比越大)、延迟越大,失真越严重。
把上面的偏差落实成一个具体到"秒"的小例子:A、B、C 三个用户真实在线区间是 A=[10,20) B=[15,30) C=[22,35)——A 在 20 秒时就已经真实登出,跟 C(22秒才上线)根本没有同时在线过。真实峰值只有 2(发生在 A、B 重叠的 [15,20))。但如果登出统一晚 5 秒才写日志(A 的登出被记成 25 而不是 20):
真实区间: A=[10,20) B=[15,30) C=[22,35) 日志区间(登出+5秒): A=[10,25) B=[15,35) C=[22,40)真实峰值(按真实时间算): 2 日志峰值(按日志时间算): 3 ← A 明明已经走了,日志却以为 A、B、C 三人在 [22,25) 同时在线!
问题就出在 t=22 C 登录的那一刻:真实世界里 A 早就走了(真实登出在 t=20),但日志记录里 A 的登出被拖到了 t=25 才写进去,所以从日志的视角看,[22,25) 这段时间"A、B、C 都在线"——一个真实世界里从没发生过的时刻,凭空被算成了峰值。这不是理论上的担忧,是可以真实构造出来的具体反例。
点击"下一步"或"播放"开始。