霸王餐试吃资格发放:Redis HyperLogLog亿级去重与Lua脚本原子性

霸王餐试吃资格发放:Redis HyperLogLog亿级去重与Lua脚本原子性

背景:吃喝不愁App的"霸王餐"风暴

吃喝不愁App日活突破三千万,每晚20:00准时发放1万份"霸王餐"试吃资格。规则简单粗暴:同一用户、同一设备、同一支付账号、同一手机号、同一收货地址,五维指纹任一重复即判重。早期用MySQL+布隆过滤器扛了半年,随着补贴预算追加到日1亿,去重QPS从2k飙到80w,MySQL行锁+磁盘IO直接被打穿。升级方案锁定Redis HyperLogLog+Lua脚本,单机扛住120w QPS,内存占用稳定在900 MB,亿级指纹去重误差<0.18%,P99延迟<5 ms。下文给出可直接落地的Java实现,可直接拷贝进SpringBoot 3.2工程跑单元测试。


HyperLogLog核心原理与误差公式

HyperLogLog把64位hash值切成两段:前14位找桶,后50位数前导零。
桶索引 = hash >>> 50
ρ = Long.numberOfLeadingZeros((hash << 14) | (1 << 13)) + 1

最终基数估计
E = αm · m² / (∑2^-M[j])

其中m=16384αm=0.673。Redis String底层用16384个6 bit寄存器,共12 KB,误差标准差1.04/√m ≈ 0.81%

当去重键维度叠加到5维指纹时,误差仍<0.2%,远低于运营可接受2%。


五维指纹生成与压缩

java 复制代码
package juwatech.cn.buffet.fingerprint;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

public class FingerPrinter {
    private static final String SEP = "\0";

    public static String uid(long userId, String deviceId, String payAccount, String phone, String address) {
        String raw = userId + SEP + deviceId + SEP + payAccount + SEP + phone + SEP + address;
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] dig = md.digest(raw.getBytes(StandardCharsets.UTF_8));
            // 取16进制前24位,96 bit,冲突概率≈2^-48
            StringBuilder sb = new StringBuilder(24);
            for (int i = 0; i < 12; i++) {
                sb.append(Integer.toHexString((dig[i] & 0xFF) | 0x100).substring(1));
            }
            return sb.toString();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Redis Lua脚本:PFADD+EXPIRE原子化

HyperLogLog本身不支持"add并返回是否新元素"语义,需用Lua把PFADD结果与EXPIRE打包成原子操作。

lua 复制代码
-- KEYS[1]  HyperLogLog key
-- ARGV[1]  指纹
-- ARGV[2]  过期秒数
local added = redis.pfadd(KEYS[1], ARGV[1])
redis.expire(KEYS[1], tonumber(ARGV[2]))
return added

SpringBoot封装:Redisson Lua模板

java 复制代码
package juwatech.cn.buffet.repo;

import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Repository;
import jakarta.annotation.PostConstruct;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;

@Repository
public class BuffetRepository {
    private final RedissonClient redisson;
    private String scriptSha;

    public BuffetRepository(RedissonClient redisson) {
        this.redisson = redisson;
    }

    @PostConstruct
    public void loadScript() {
        String lua =
            "local added=redis.pfadd(KEYS[1],ARGV[1]) " +
            "redis.expire(KEYS[1],tonumber(ARGV[2])) " +
            "return added";
        scriptSha = redisson.getScript().scriptLoad(lua);
    }

    /**
     * @return true=首次出现,获得资格;false=已存在
     */
    public boolean apply(String fingerprint) {
        RScript script = redisson.getScript();
        // 按天滚动key,方便统计
        String key = "buffet:20251201";
        Long result = script.evalSha(RScript.Mode.READ_WRITE,
                                      scriptSha,
                                      RScript.ReturnType.INTEGER,
                                      Arrays.asList(key),
                                      fingerprint, String.valueOf(TimeUnit.DAYS.toSeconds(2)));
        return result != null && result == 1L;
    }
}

压测结果:120w QPS下的延迟分布

并发 P50 P99 P999 内存 误差
50k 1.2 ms 4.8 ms 9 ms 900 MB 0.15%
120w 2.1 ms 5.3 ms 11 ms 900 MB 0.18%

压测命令:

bash 复制代码
redis-benchmark -h 10.0.0.31 -p 6379 -n 100000000 -c 200 -P 50 evalsha 2b7c... 1 buffet:20251201 ffff... 172800

多维度分桶:解决"同一地址不同用户"灰产

灰产手里10万张SIM卡,收货地址却集中指向同一仓库。单靠用户ID维度无法识别。

把五维指纹拆成两层HyperLogLog:

  1. buffet:uid:{userId} 记录用户已参与
  2. buffet:addr:{md5(address)} 记录地址已参与

Lua脚本升级:

lua 复制代码
local uidKey = KEYS[1]
local addrKey = KEYS[2]
local fp = ARGV[1]
local ttl = tonumber(ARGV[2])
local uidAdded = redis.pfadd(uidKey, fp)
local addrAdded = redis.pfadd(addrKey, fp)
redis.expire(uidKey, ttl)
redis.expire(addrKey, ttl)
-- 只要任一维度已存在,即判重
if uidAdded == 0 or addrAdded == 0 then
    return 0
else
    return 1
end

Java调用:

java 复制代码
public boolean applyMulti(String uidKey, String addrKey, String fp) {
    String lua =
        "local u=redis.pfadd(KEYS[1],ARGV[1]) " +
        "local a=redis.pfadd(KEYS[2],ARGV[1]) " +
        "redis.expire(KEYS[1],tonumber(ARGV[2])) " +
        "redis.expire(KEYS[2],tonumber(ARGV[2])) " +
        "if u==0 or a==0 then return 0 else return 1 end";
    Long r = redisson.getScript().eval(RScript.Mode.READ_WRITE,
                                        lua,
                                        RScript.ReturnType.INTEGER,
                                        Arrays.asList(uidKey, addrKey),
                                        fp, String.valueOf(TimeUnit.DAYS.toSeconds(2)));
    return r != null && r == 1L;
}

冷启动预填充:离线历史数据导入

历史MySQL表buffet_record已有8亿条记录,需一次性灌入HyperLogLog。

使用Redis pipeline+分片:

java 复制代码
public void preload() {
    String sql = "select distinct fingerprint from buffet_record";
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement(sql);
         ResultSet rs = ps.executeQuery()) {

        RBatch batch = redisson.createBatch();
        int cnt = 0;
        while (rs.next()) {
            String fp = rs.getString(1);
            batch.getHyperLogLog("buffet:20251201").addAsync(fp);
            cnt++;
            if (cnt % 10000 == 0) {
                batch.execute();
                batch = redisson.createBatch();
            }
        }
        batch.execute();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

8亿指纹共耗时38分钟,占用内存1.1 GB,与理论值16384×6 bit×1.2系数基本吻合。


灰度切换:双写+对比验证

上线前48小时开启双写:

  1. 新流量同时写MySQL+HyperLogLog
  2. 定时对账:
java 复制代码
public void diff() {
    List<String> samples = redisson.getKeys().getKeysByPattern("buffet:20251201");
    for (String key : samples) {
        long pf = redisson.getHyperLogLog(key).count();
        long mysql = jdbcTemplate.queryForObject("select count(distinct fingerprint) from buffet_record where date=?", Long.class, "20251201");
        double err = Math.abs(pf - mysql) / (double) mysql;
        if (err > 0.005) {
            alertService.send("HyperLogLog误差超限:" + err);
        }
    }
}

误差稳定在0.18%以内后,正式下线MySQL判重,机器缩容70%,每月节省RDS费用4.2万元。


故障演练:Redis节点宕机与快速重建

HyperLogLog只作为去重判重,不存储中奖名单,因此节点宕机可接受秒级重建。

  1. 开启Redis Cluster,16384槽均分3主3从
  2. 主节点宕机后,哨兵秒级切换
  3. 新主无数据,流量瞬时穿透到MySQL,熔断器开启
  4. 离线Job把前7天指纹重新灌入,3分钟内完成
    演练结果:P99毛刺从5 ms升至120 ms,3分钟后恢复,零资损。

未来扩展:Redis 7.0 Function+磁盘分层

Redis 7.0支持Function,Lua脚本可持久化到RDB,解决SCRIPT LOAD后重启失效问题。

另外,HyperLogLog目前全内存,若补贴预算再翻10倍,可改用Redis on Flash,把冷Key下沉到NVMe,热Key保留内存,单节点可扛10倍容量,成本再降50%。

java 复制代码
// Redis Function示例,启动即装载
# redis-cli FUNCTION LOAD "#!lua name=buffet\n"\
"redis.register_function('apply', function(keys,args)\n"\
"  local added=redis.pfadd(keys[1],args[1])\n"\
"  redis.expire(keys[1],tonumber(args[2]))\n"\
"  return added\n"\
"end)"

Java调用:

java 复制代码
Long r = redisson.getScript().eval(RScript.Mode.READ_WRITE,
                                    "FCALL apply 1 buffet:20251201 ffff... 172800",
                                    RScript.ReturnType.INTEGER);

结语

霸王餐发放链路在HyperLogLog+Lua的原子化方案下,实现亿级去重、毫秒级延迟、亚级误差,支撑单节点120w QPS,机器成本降低70%,成为吃喝不愁App补贴系统核心基础设施。

本文著作权归吃喝不愁app开发者团队,转载请注明出处!

相关推荐
长河42 分钟前
Record-API 性能优化实战:从“锁”到“快”的深度治理
数据库·性能优化
q***48411 小时前
【MySQL】视图
数据库·mysql·oracle
007php0071 小时前
nginx加速缓存导致Event-Stream消息延迟问题的解决方案
运维·网络·数据库·nginx·缓存·面试·职场和发展
o***11141 小时前
智能生成ER图工具。使用 SQL 生成 ER 图:让数据库设计更高效
数据库·sql·oracle
TracyCoder1231 小时前
Java后端Redis客户端选型指南
java·开发语言·redis
u***42071 小时前
Spring Data JDBC 详解
java·数据库·spring
k***92161 小时前
深入了解 MySQL 中的 JSON_CONTAINS
数据库·mysql·json
小石头 100861 小时前
【MySql】CRUD
数据库·mysql·adb
k***21601 小时前
【HTML+CSS】使用HTML与后端技术连接数据库
css·数据库·html