候选池
候选池的实现通常不是单一中间件能够完成的,而是需要多种中间件结合,根据业务需求进行选择和组合。高并发低延迟和实时更新常见的最佳实践是缓存+消息队列+流计算的组合架构,确保查询速度快,同时候选池数据始终是最新的。
结合 Redis(缓存)+ Kafka(消息队列)+ Flink(流计算)+ Elasticsearch(精准查询),具体方案如下:
1. Redis + Elasticsearch(高并发低延迟查询)
- Redis(第一层快速筛选):
- 采用 Set / SortedSet(ZSet) 存储商家 ID,快速获取符合基本条件的候选池。
- 热门商家数据存入 Redis,保证高并发下的极低延迟(<1ms)。
- Elasticsearch(第二层精准查询):
- 适用于需要复杂筛选(如按地域、评分、历史点击率等条件过滤商家)。
- 采用倒排索引,支持多维度查询,降低数据库压力。
2. Kafka + Flink(实时更新候选池)
- Kafka(数据实时流入):
- 采集商家数据、广告投放效果、用户行为等信息。
- 生产者(Producer)不断将商家状态变化推送到 Kafka Topic。
- Flink(实时计算 & 规则更新):
- 监听 Kafka 的数据流,计算商家投放质量评分、活跃度、转化率等关键指标。
- 计算结果存入 Redis / Elasticsearch,实时更新候选池。
- 例如,如果某商家点击率下降,Flink 计算后可自动降低该商家排名或剔除候选池。
架构流程
- 商家投放数据变化(广告效果、用户点击、转化率等)→ 写入 Kafka
- Flink 监听 Kafka 数据流,计算商家最新评分、排名、投放效果等信息
- Flink 计算完成后,更新 Redis / Elasticsearch:
- Redis:存储高频查询的热门候选商家
- Elasticsearch:存储全量商家数据,提供复杂查询能力
- DSP(广告系统)查询候选池时:
- 先查 Redis,如果命中直接返回,保证高并发低延迟
- 未命中则查询 Elasticsearch,获取更精准候选池
优化方案
异步更新 Elasticsearch
在DMP模块中进行异步更新Elasticsearch(ES),主要是为了避免同步写入时的性能瓶颈,提升系统的吞吐量和实时性。
✅ 方案:基于 Kafka 进行异步更新
-
流程:
- DMP 计算出新的商家数据后,先将数据写入 Redis 作为缓存。
- 同时,将更新消息(商家ID、最新状态、得分等)异步推送到 Kafka。
- 独立的消费端(ES Writer) 从 Kafka 读取消息,并批量写入 Elasticsearch。
-
优点:
- 高吞吐量:Kafka 可以承载大量数据,并提供顺序消费、重试机制。
- 解耦合:DMP 只需写入 Kafka,不需要关心 ES 的更新速度,ES 处理慢不会影响 Redis。
- 批量更新:消费端可以批量写入 ES,减少 IO 负担,提高写入效率。
数据一致性
Redis 和 Elasticsearch 更新数据不需要做成事务
不建议使用分布式事务(如 2PC)来保证 Redis 和 Elasticsearch 的数据一致性,而应使用 最终一致性方案。
1. 为什么不建议使用事务?
在 Redis 和 Elasticsearch 之间执行 强一致性事务(如 2PC),有以下缺点:
- 性能损耗:分布式事务(XA/2PC)会严重影响 Redis 和 ES 的吞吐量,导致 DSP 查询和 DMP 更新变慢。
- 事务支持问题:
- Redis 没有原生的分布式事务支持,只能通过 Lua 脚本或
MULTI
机制实现本地事务。 - Elasticsearch 也不支持事务,其索引更新是幂等的,通常采用版本控制(
_version
)或者写入时序保证数据一致性。
- Redis 没有原生的分布式事务支持,只能通过 Lua 脚本或
- 分布式架构原则:在大规模数据流(高并发、高吞吐)场景下,强一致性不现实,最终一致性是更好的选择。
2. 采用“最终一致性”解决方案
✅ 方案 1:Kafka + 双写,ES 异步更新
流程
- 先更新 Redis(保证查询实时性)。
- 再发送更新事件到 Kafka,让异步消费者更新 Elasticsearch。
- ES 消费端监听 Kafka 事件,批量更新 Elasticsearch,避免影响 Redis 的高并发查询能力。
Kafka 发送和 Redis 更新解耦
如果 Kafka 发送是独立的,即使 Redis 更新失败,Kafka 仍然可以收到数据,ES 仍然会更新。
public void updateMerchantData(Merchant merchant) {
try {
// 1. 更新 Redis
redisTemplate.opsForHash().put("merchant_info", merchant.getId(), merchant);
} catch (Exception e) {
log.error("Redis 更新失败: {}", e.getMessage());
// 继续执行 Kafka 发送,确保 ES 仍然可以更新
}
// 2. 发送 Kafka 消息(无论 Redis 是否成功)
kafkaTemplate.send("merchant-update-topic", merchant.getId(), merchant);
}
Kafka 消费端异步更新 ES,补偿机制(Redis 失败时,Kafka 仍发送消息,并让 Kafka 消费端尝试补偿 Redis)
@KafkaListener(topics = "merchant-update-topic", groupId = "es-writer-group")
public void consumeMerchantUpdate(Merchant merchant) {
try {
// 1. 先补偿 Redis 更新(如果之前失败)
redisTemplate.opsForHash().put("merchant_info", merchant.getId(), merchant);
} catch (Exception e) {
log.error("Kafka 消费端补偿 Redis 失败: {}", e.getMessage());
}
// 2. 更新 Elasticsearch
bulkUpdateElasticsearch(Collections.singletonList(merchant));
}
优点
- 高并发,低延迟:Redis 更新是同步的,Kafka+ES 更新是异步的,保证查询速度。
- 支持批量更新:Kafka 消费端可以批量写入 ES,减少 ES 的写入负担。
- 数据最终一致:即使 ES 更新失败,Kafka 也可以重试,不会丢数据。
缺点
- ES 更新稍有延迟(通常 <1s)。
Redis 同步更新和 Kafka+ES 的异步更新做到解耦,Redis 更新失败不会影响 Kafka+ES 的异步更新结果。
如果 Redis 同步更新成功,Kafka 消息发送失败,指数级重试 3 次,如果依然失败,使用 Prometheus + Grafana 监控,页面告警,邮件告警,把消息存储到本地消息表中 Mysql。
这样,当 Kafka 消息发送失败次数超过设定阈值(如 5 次),Prometheus 会触发 Grafana 告警,Alertmanager 负责发送邮件通知。🚀
使用 Elastic-Job 定时任务,扫描本地消息表中的数据进行发送,发送成功后进行逻辑标记删除,一定时期对消息表中已发送成功的数据进行归档,归档后进行物理删除。
归档数据
归档数据可以存储在不同的地方,取决于业务需求、查询频率和存储成本。以下是几种常见的归档方案:
方案 1:归档到 MySQL 的历史表(最常见)
适用场景:
- 需要保留消息历史记录,但查询频率不高。
- 依然希望能通过 SQL 快速查询归档数据。
- 适用于结构化数据的存储。
实现方法:
- 创建归档表(结构与原表一致)
CREATE TABLE local_message_queue_archive LIKE local_message_queue;
- 定期归档已处理的消息
INSERT INTO local_message_queue_archive SELECT * FROM local_message_queue WHERE status = 'PROCESSED' AND created_at < NOW() - INTERVAL 30 DAY;
- 删除主表中已归档的数据
DELETE FROM local_message_queue WHERE status = 'PROCESSED' AND created_at < NOW() - INTERVAL 30 DAY;
- 设置定时任务(如 MySQL Event 或 Spring Task 定期执行)
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行 public void archiveAndCleanMessages() { jdbcTemplate.update("INSERT INTO local_message_queue_archive SELECT * FROM local_message_queue WHERE status = 'PROCESSED' AND created_at < NOW() - INTERVAL 30 DAY"); jdbcTemplate.update("DELETE FROM local_message_queue WHERE status = 'PROCESSED' AND created_at < NOW() - INTERVAL 30 DAY"); }
优点:
✅ 结构化存储,查询方便,可直接用 SQL 查找特定消息。
✅ 归档数据仍然可以通过数据库管理工具查看,方便运维。
✅ 适用于查询量较小的场景,存储和维护成本较低。
缺点:
❌ MySQL 表数据量仍然会增长,虽然影响较小,但可能需要定期清理更老的数据(如保留 6 个月)。
❌ 如果数据量特别大,查询可能会慢(可以考虑分库分表或迁移到更适合 OLAP 查询的存储)。
方案 2:归档到 Elasticsearch(适用于高效检索)
适用场景:
- 需要对历史消息进行全文检索或复杂查询。
- 需要高效的查询性能,能快速查找某个消息的状态。
- 日志、交易记录等需要较快查询的场景。
实现方法:
- 创建 Elasticsearch 索引
PUT local_message_archive { "mappings": { "properties": { "message_id": { "type": "keyword" }, "status": { "type": "keyword" }, "payload": { "type": "text" }, "created_at": { "type": "date" } } } }
- 写入归档数据
- 使用 Kafka 消费归档队列,将消息写入 Elasticsearch:
@KafkaListener(topics = "message-archive") public void archiveMessage(String messageJson) { IndexRequest request = new IndexRequest("local_message_archive") .source(messageJson, XContentType.JSON); elasticsearchClient.index(request, RequestOptions.DEFAULT); }
- 定期清理 MySQL 主表
DELETE FROM local_message_queue WHERE status = 'PROCESSED' AND created_at < NOW() - INTERVAL 30 DAY;
优点:
✅ 查询速度快,适用于高并发、高速检索的场景。
✅ 可以进行全文搜索,查询条件更灵活(如按 message_id 或 payload 关键词搜索)。
✅ 适合大数据量的归档,比 MySQL 存储效率更高。
缺点:
❌ 需要维护 Elasticsearch 集群,运维成本比 MySQL 高。
❌ 存储成本较高,ES 消耗的磁盘空间比 MySQL 多。
方案 3:归档到 Kafka(适用于流式计算 + 冷存储)
适用场景:
- 归档数据本身不需要经常查询,但可能用于大数据分析或定期批量处理。
- 适用于离线数据分析、机器学习等场景。
- 适用于数据流式消费,如后续再写入 HDFS、ClickHouse、Doris 等数据仓库。
实现方法:
- 创建 Kafka 归档主题
kafka-topics.sh --create --topic message-archive --bootstrap-server kafka:9092 --partitions 3 --replication-factor 2
- 将归档消息推送到 Kafka
kafkaTemplate.send("message-archive", messageJson);
- 消费 Kafka 归档数据,并存入 HDFS/ClickHouse
- 以 Flink、Spark Streaming 或 Kafka Consumer 方式消费 Kafka 消息:
@KafkaListener(topics = "message-archive") public void archiveToHDFS(String messageJson) { hdfsClient.write("/archive/messages/" + System.currentTimeMillis() + ".json", messageJson); }
优点:
✅ 适用于数据分析场景,可以结合大数据生态(Flink、Spark、Hive)。
✅ Kafka 处理吞吐量大,适用于超大规模数据归档。
✅ 结合 HDFS,可以存储 PB 级别的历史数据。
缺点:
❌ Kafka 只是消息存储,仍然需要落地到 HDFS、ClickHouse 等持久化存储,否则 Kafka 日志会过期。
❌ 直接查询 Kafka 数据比较困难,不如 Elasticsearch 方便。
最终推荐
方案 | 适用场景 | 存储方式 | 查询性能 | 成本 | 维护难度 |
---|---|---|---|---|---|
MySQL 归档表 | 中小型项目,查询量不高 | 结构化存储 | 一般 | 低 | 低 |
Elasticsearch 归档 | 高速检索,日志分析 | 文档存储 | 高 | 中 | 中 |
Kafka + HDFS | 批量归档,离线计算 | 流式存储 | 低 | 低 | 高 |
- 如果只是存储历史消息,且查询不频繁,推荐 MySQL 历史表(方案 1)。
- 如果要高效检索归档消息(如搜索某个 message_id),推荐 Elasticsearch(方案 2)。
- 如果主要用于数据分析,不需要频繁查询,推荐 Kafka + HDFS(方案 3)。
异步批量消费 kafka 消息进行更新 Elasticsearch,手动提交 offset,如果消费失败,进行重试,同时使用 grafana+prometheus 进行重试次数记录,超过三次后则进行页面告警,同时发送告警邮件,进行人为介入。
商家竞价策略
📌 常见竞价方式
竞价模式 | 全称 | 计费方式 | 适用场景 |
---|---|---|---|
CPM | Cost Per Mille | 按 每千次曝光 付费 | 品牌推广,提升曝光量 |
CPC | Cost Per Click | 按 每次点击 付费 | 关注点击转化,提高流量 |
CPA | Cost Per Action | 按 每次转化(如购买、注册) 付费 | 目标是提高转化率 |
CPS | Cost Per Sale | 按 每次销售 付费 | 适合电商,按成交量计费 |
CPI | Cost Per Install | 按 每次应用安装 付费 | 适合 App 下载推广 |
CPV | Cost Per View | 按 每次完整观看 付费 | 适用于视频广告 |
ROAS | Return On Ad Spend | 按 广告支出回报比 竞价 | 适合效果导向型广告,优化收益 |
📌 更智能的竞价策略
除了以上常见竞价方式,一些 智能竞价 策略也广泛应用,例如:
- oCPM(优化千次展示成本):DSP 会自动优化投放,提升转化率。
- oCPC(优化点击成本):DSP 预测哪些用户更可能点击,自动优化出价。
- tCPA(目标转化成本):广告主设定目标 CPA,DSP 自动调整出价,以实现目标转化成本。
- tROAS(目标广告支出回报比):广告主设定目标 ROAS,DSP 根据预估收益调整出价。
📌 结论
✔ 商家设定的竞价方式不止 CPM、CPC、CPA,还包括 CPS、CPI、CPV、ROAS 等多种模式。
✔ DSP 计算 eCPM 时,会将各种竞价方式转换为统一标准进行比较,以保证公平竞价。
✔ 智能竞价策略(如 oCPC、tCPA、tROAS)能帮助广告主更高效地优化投放效果 🚀。
eCPM
eCPM (Effective Cost Per Mille,有效千次展示成本) 是广告投放中的一个关键指标,全称 Effective Cost Per Mille,意思是 “有效千次展示成本”。它表示 每 1000 次广告展示,广告主愿意支付的费用,用于衡量广告的价值和竞争力。
eCPM 是 所有广告格式(CPC、CPA、CPM)都可以转换到的统一竞价标准,这样 DSP 在竞价时可以公平比较不同类型的广告。
📌 eCPM 计算公式
eCPM =(总广告收益 / 总展示次数)× 1000
或者,对不同的竞价模式进行转换:
- CPM(千次曝光出价)广告:
eCPM = 直接的 CPM 出价
- CPC(按点击付费)广告:
eCPM = CPC × CTR × 1000
- CPC(Cost Per Click):广告主愿意为每次点击支付的费用
- CTR(Click Through Rate):广告点击率(点击数 / 曝光数)
- CPA(按转化付费)广告:
eCPM = CPA × CVR × CTR × 1000
- CPA(Cost Per Acquisition):广告主愿意为每次转化(如购买、注册)支付的费用
- CVR(Conversion Rate):转化率(转化数 / 点击数)
📌 eCPM 在广告竞价中的作用
在 RTB(实时竞价) 中,广告主可能使用不同的出价方式(CPC、CPM、CPA)。为了 公平比较,DSP 会将所有广告的出价转换为 eCPM,然后选取 eCPM 最高 的广告进行竞价。
✅ 高 eCPM = 高收益 = 更有可能胜出竞价
📌 举例:不同广告竞价方式的 eCPM 计算
假设某广告位上有 3 个商家竞价:
广告商家 | 出价方式 | 出价 | 预估 CTR | 预估 CVR | 计算 eCPM |
---|---|---|---|---|---|
商家 A | CPM | ¥8 / 千次曝光 | - | - | ¥8 |
商家 B | CPC | ¥2 / 点击 | 5% | - | ¥2 × 0.05 × 1000 = ¥10 |
商家 C | CPA | ¥100 / 转化 | 4% | 2% | ¥100 × 0.02 × 0.04 × 1000 = ¥8 |
结果:
- 商家 B 的 eCPM = ¥10,最高,最终胜出!
- 即使商家 A 的 CPM 出价直接是 ¥8,仍然输给了商家 B,因为按点击计费的 B 在该广告位的收益更高。
📌 结论
✅ eCPM 是衡量广告收益的标准指标,能将 CPC、CPM、CPA 转换为统一标准,确保公平竞价。
✅ 在 RTB 竞价中,eCPM 最高的广告胜出,获得该广告位的曝光机会。
✅ 即使是 CPC 或 CPA 竞价,也可能比 CPM 竞价更有竞争力,如果其 eCPM 计算后更高的话。
eCPM 是 DSP 计算的! eCPM 是 DSP 计算最优出价、提高广告收益 的核心指标 🚀。
📌 计算主体
在 RTB(实时竞价) 过程中,eCPM 的计算由 DSP(需求方平台) 完成,而 Ad Exchange(广告交易平台) 只是负责接收 DSP 提供的出价,并选出最高的广告进行展示。
1️⃣ DSP 计算 eCPM
- 商家在 DSP 端设置竞价策略(CPM、CPC、CPA 等)。
- DSP 计算 eCPM:将不同的竞价方式(CPC、CPA)转换为 eCPM 统一衡量,方便与 CPM 直接竞价的广告比较。
- CPM 出价:eCPM = CPM 出价
- CPC 出价:eCPM = CPC × 预估 CTR × 1000
- CPA 出价:eCPM = CPA × 预估 CVR × 预估 CTR × 1000
- DSP 计算最终出价,然后将结果提交给 Ad Exchange。
2️⃣ Ad Exchange 只负责筛选最高出价
- Ad Exchange 接收多个 DSP 提供的 eCPM 出价。
- Ad Exchange 选出 eCPM 最高的广告,并将广告展示给用户。
📌 为什么是 DSP 计算 eCPM?
✅ DSP 更了解广告主的策略和目标(如转化率、点击率等),能够根据商家需求 优化出价。
✅ Ad Exchange 主要负责竞价撮合,并不会深入计算不同竞价方式的换算。
✅ DSP 需要通过 eCPM 让不同竞价方式(CPC、CPA、CPM)公平竞争,确保商家能以最优方式获得广告曝光。
📌 结论
✔ eCPM 是 DSP 计算的,而 Ad Exchange 只是比较 DSP 提供的出价,选出最高者。
✔ DSP 计算 eCPM 的目的是为了确保不同竞价方式的广告能够公平竞争,提高竞价效率和广告收益 🚀。
广告位
就某一个固定的广告位来说,最终被选上的通常只有一个商家,因为 Ad Exchange 经过竞价后,会选择出价最高且符合条件的商家来展示广告。
但是如果是多个广告位(例如电商平台搜索结果页中的多个广告栏位),那么这些广告位是并行的,每个广告位的竞价是独立进行的,最终会有多个不同商家的广告同时被选中展示。
📌 具体竞价流程(以单个广告位为例)
1️⃣ DSP 从 Elasticsearch 查询候选广告(如 50~100 条)。
2️⃣ 筛选掉不符合要求的广告(如预算不足、质量低、定向不匹配)。
3️⃣ 如果是自动竞价,计算每个广告的 eCPM 出价。
4️⃣ DSP 提交候选广告到 Ad Exchange 进行 RTB 竞价。
5️⃣ Ad Exchange 在所有 DSP 竞价广告中,选出 eCPM 最高的广告。
6️⃣ 最终胜出的广告将被展示在该广告位上。
📌 多广告位是并行的
在一个电商平台的搜索页面中,可能有 多个广告位(例如 5~10 个广告展示位)。这些广告位的竞价是独立进行的,即:
- 每个广告位都会有自己的竞价过程。
- 多个广告位可能会被同一家商家拿下多个位置,但通常不同广告位会有不同的商家。
- 每个广告位的竞价策略可能不同(例如:搜索页顶部广告 vs 右侧推荐广告)。
📌 举例
假设用户在某电商平台搜索 “智能手表”,页面有 3 个广告位:
广告位 | 竞价商家 | eCPM(预估出价) | 选中的商家 |
---|---|---|---|
广告位 1(顶部) | 商家 A, B, C | A: ¥20, B: ¥18, C: ¥15 | 商家 A |
广告位 2(侧边栏) | 商家 D, E, F | D: ¥12, E: ¥14, F: ¥10 | 商家 E |
广告位 3(搜索结果中部) | 商家 G, H, I | G: ¥16, H: ¥17, I: ¥13 | 商家 H |
- 广告位 1 的最终胜出者是 商家 A,因为 A 的 eCPM 最高(¥20)。
- 广告位 2 由 商家 E 胜出(¥14)。
- 广告位 3 由 商家 H 胜出(¥17)。
- 这 3 个广告位是 并行竞价的,相互之间没有直接影响,每个位置选出一个最优广告。
📌 结论
✅ 单个广告位最终只会选出一个商家(即 eCPM 最高的广告)。
✅ 多个广告位之间是并行关系,每个广告位独立竞价,最终会由多个不同商家的广告被选中展示。
✅ 同一个商家可能拿下多个广告位,但这取决于他们的竞价策略和预算。
这样设计可以最大化广告收益,同时确保广告投放的公平性和多样性 🚀。
DSP 查询 Elasticsearch 的条件
在 DSP 查询 Elasticsearch 时,通常会使用一些关键条件来筛选出符合要求的广告,确保从候选池中找到与广告位匹配的广告。主要的查询条件包括:
- 广告位信息(Ad Placement)
- 广告位的类型(比如:横幅广告、视频广告、推荐位等)。
- 广告位的尺寸(如:300x250、728x90 等)。
- 广告位的目标(如:品牌曝光、流量引导、转化等)。
- 用户信息(User Data)
- 用户画像:例如,年龄、性别、地域、兴趣等(可能通过 DMP 或第一方数据传递给 DSP)。
- 用户行为:历史浏览、购买记录、互动记录等。
- 商家广告定向(Advertiser Targeting)
- 商家定向条件:例如广告主设置的目标用户群体,特定的地理位置、设备类型、时间等。
- 广告类型:广告主设置的广告类型(如 CPC、CPM、CPA 等),以匹配特定的广告位和目标。
- 竞价信息(Bidding Information)
- 商家的竞价策略(例如:CPM、CPC、CPA 等),确保广告的出价符合要求。
- 是否符合广告主的最大出价范围或预算限制。
- 广告内容与创意(Creative Content)
- 广告的创意类型,如图像、视频、文字等,匹配广告位的要求。
- 创意是否已通过审核,符合平台规范。
查询之后,DSP 通常还需要进一步过滤掉不符合条件的广告。这些过滤可能包括以下几种:
- 预算和竞价过滤
- 如果商家的广告预算已用完或出价过低,DSP 会过滤掉这些广告,避免提交不符合竞价要求的广告。
- 定向条件过滤
- 根据广告主的定向条件(如地域、设备类型、兴趣等),去除不符合定向规则的广告。例如,某广告仅面向移动设备用户,其他设备的广告将被过滤。
- 创意过滤
- 如果广告创意不符合广告位的格式要求(如尺寸不匹配),或者广告创意内容违反平台规范,则会被过滤掉。
- 频次过滤
- 为了避免广告的重复展示,DSP 可能会基于用户历史曝光频次(Frequency Cap)进行过滤,确保广告不会频繁展示给同一用户。
- 效能过滤
- DSP 可能会根据广告的历史表现(如 CTR、CVR)来进行过滤,优先展示历史表现较好的广告,提高广告效果。
Elasticsearch 广告数据结构
在 Elasticsearch (ES) 中,完整的商家广告数据应该包含广告基本信息、投放规则、定向策略、竞价信息、广告素材等内容。以下是 ES 中存储的一份完整广告数据示例,适用于DSP、Ad Exchange、推荐系统等场景。
{
"ad_id": "ad_123456",
"merchant_id": "m_7890",
"ad_name": "iPhone 15 Pro 限时折扣",
"ad_type": "商品推广",
"ad_category": ["手机", "苹果", "电子产品"],
"status": "active",
"budget": {
"total_budget": 50000,
"daily_budget": 2000
},
"bidding": {
"strategy": "CPC",
"max_bid": 1.5,
"current_bid": 1.2
},
"targeting": {
"geo": ["Shanghai", "Beijing"],
"device": ["mobile", "tablet"],
"age_range": ["18-25", "26-35"],
"gender": ["male", "female"],
"interests": ["科技", "数码", "苹果粉丝"],
"keywords": ["iPhone 15", "智能手机", "苹果官网"],
"user_behavior": {
"viewed_products": ["iPhone 14", "AirPods Pro"],
"purchased_categories": ["智能手机", "数码配件"]
}
},
"schedule": {
"start_time": "2024-03-10T08:00:00Z",
"end_time": "2024-03-31T23:59:59Z",
"time_slots": ["08:00-12:00", "18:00-22:00"]
},
"ad_creatives": [
{
"creative_id": "c_001",
"format": "image",
"url": "https://cdn.example.com/ads/iphone15.jpg",
"width": 1080,
"height": 1920
},
{
"creative_id": "c_002",
"format": "video",
"url": "https://cdn.example.com/ads/iphone15.mp4",
"duration": 15
}
],
"landing_page": "https://www.apple.com/cn/iphone-15-pro/",
"metrics": {
"impressions": 15000,
"clicks": 1200,
"ctr": 0.08,
"conversions": 320,
"cvr": 0.026,
"spent": 1800
},
"created_at": "2024-03-01T10:00:00Z",
"updated_at": "2024-03-09T15:30:00Z"
}
Redis 存储结构设计
如果要在 Redis 中存一份完整的广告数据,主要目的是 加速广告匹配,避免每次都查询 Elasticsearch。由于 Redis 是键值存储,我们需要合理设计 数据结构,以 高效存取、快速查询 为目标。
Redis 适合的存储方式
- 方式 1(适合广告 ID 查询):使用 Hash 存储完整的广告数据(适合存储单条广告信息)
- 方式 2(适合筛选广告):使用 Sorted Set、Set、Bitmap、HyperLogLog 等结构进行广告索引
方式 1:使用 Redis Hash 存储广告完整数据
✅ 通过 ad_id
直接获取广告详情
✅ 适用于高频查询
HSET ad:123456 ad_id "123456"
HSET ad:123456 merchant_id "7890"
HSET ad:123456 ad_name "iPhone 15 Pro 限时折扣"
HSET ad:123456 ad_type "商品推广"
HSET ad:123456 ad_category "手机,苹果,电子产品"
HSET ad:123456 status "active"
HSET ad:123456 budget_total 50000
HSET ad:123456 budget_daily 2000
HSET ad:123456 bidding_strategy "CPC"
HSET ad:123456 bidding_max 1.5
HSET ad:123456 bidding_current 1.2
HSET ad:123456 targeting_geo "Shanghai,Beijing"
HSET ad:123456 targeting_device "mobile,tablet"
HSET ad:123456 targeting_age "18-25,26-35"
HSET ad:123456 targeting_gender "male,female"
HSET ad:123456 targeting_interests "科技,数码,苹果粉丝"
HSET ad:123456 targeting_keywords "iPhone 15,智能手机,苹果官网"
HSET ad:123456 landing_page "https://www.apple.com/cn/iphone-15-pro/"
HSET ad:123456 impressions 15000
HSET ad:123456 clicks 1200
HSET ad:123456 ctr 0.08
HSET ad:123456 conversions 320
HSET ad:123456 cvr 0.026
HSET ad:123456 spent 1800
HSET ad:123456 created_at "2024-03-01T10:00:00Z"
HSET ad:123456 updated_at "2024-03-09T15:30:00Z"
方式 2:构建广告索引,加速定向查询
✅ 需要根据 地域、设备、兴趣标签 快速筛选广告
✅ 避免全量遍历,提高查询效率
1. 地域索引
SADD targeting:geo:Shanghai ad_123456 ad_789012
SADD targeting:geo:Beijing ad_123456 ad_654321
查询:查找投放到上海的所有广告:
SMEMBERS targeting:geo:Shanghai
2. 设备索引
SADD targeting:device:mobile ad_123456 ad_654321
SADD targeting:device:tablet ad_123456
查询:查找投放到 Mobile 设备的所有广告:
SMEMBERS targeting:device:mobile
3. 年龄段索引
SADD targeting:age:18-25 ad_123456 ad_654321
SADD targeting:age:26-35 ad_123456 ad_789012
查询:查找投放给 18-25 岁用户的广告:
SMEMBERS targeting:age:18-25
4. 兴趣标签索引
SADD targeting:interests:科技 ad_123456 ad_789012
SADD targeting:interests:数码 ad_123456 ad_654321
SADD targeting:interests:苹果粉丝 ad_123456
查询:查找兴趣是“科技”的广告:
SMEMBERS targeting:interests:科技
5. 关键词索引
SADD targeting:keywords:iPhone15 ad_123456 ad_654321
SADD targeting:keywords:智能手机 ad_123456
查询:查找关键词包含“iPhone 15”的广告:
SMEMBERS targeting:keywords:iPhone15
6. 活跃广告集合
SADD active_ads ad_123456 ad_654321 ad_789012
查询:获取所有活跃广告:
SMEMBERS active_ads
如何进行广告检索
假设用户 设备:Mobile,地域:上海,兴趣:科技,查询步骤如下:
- 查询
targeting:device:mobile
→{ad_123456, ad_654321}
- 查询
targeting:geo:Shanghai
→{ad_123456, ad_789012}
- 查询
targeting:interests:科技
→{ad_123456, ad_789012}
- 取交集
SINTER targeting:device:mobile targeting:geo:Shanghai targeting:interests:科技
SINTER targeting:device:mobile targeting:geo:Shanghai targeting:interests:科技
返回 {ad_123456}
,最终广告 ID = ad_123456
。
然后可以:
HGETALL ad:123456
拿到完整广告数据
最终实现:广告查询速度更快,广告匹配精准度更高! 🚀
Elasticsearch 用户画像数据
在 Elasticsearch 中存储 用户画像数据,我们需要设计一个 结构化且易于查询的数据模型,以便支持高效的查询和筛选。用户画像数据通常包括用户的基本信息、行为信息、兴趣、偏好、历史活动等,通常是一个 文档型 存储,每个用户对应一个文档。
{
"user_id": "123456",
"user_name": "张三",
"age": 29,
"gender": "male",
"location": {
"city": "北京",
"province": "北京",
"country": "中国"
},
"device": {
"os": "Android",
"device_type": "手机",
"device_id": "device12345"
},
"interests": [
"科技",
"数码",
"篮球",
"苹果产品"
],
"browsing_history": [
{
"product_id": "987654",
"category": "电子产品",
"sub_category": "手机",
"brand": "苹果",
"timestamp": "2024-03-01T10:00:00Z"
},
{
"product_id": "543210",
"category": "电子产品",
"sub_category": "耳机",
"brand": "BOSE",
"timestamp": "2024-02-25T12:00:00Z"
}
],
"purchase_history": [
{
"product_id": "123456",
"category": "家居",
"sub_category": "厨房用品",
"amount": 150,
"purchase_date": "2024-01-10T10:30:00Z"
}
],
"search_history": [
{
"query": "iPhone 15",
"timestamp": "2024-03-01T09:00:00Z"
},
{
"query": "智能手表",
"timestamp": "2024-02-28T14:30:00Z"
}
],
"social_media_activity": {
"facebook": {
"followers": 350,
"likes": 120,
"shares": 35
},
"weibo": {
"followers": 500,
"likes": 200,
"shares": 75
}
},
"loyalty_program": {
"points": 1200,
"membership_level": "Gold"
},
"recommendations": [
{
"product_id": "987654",
"category": "电子产品",
"sub_category": "手机",
"reason": "你最近浏览过苹果手机"
},
{
"product_id": "654321",
"category": "电子产品",
"sub_category": "耳机",
"reason": "根据你的购买历史推荐"
}
],
"preferences": {
"payment_method": "信用卡",
"shipping_address": "北京市朝阳区XXX小区",
"communication_preferences": {
"email": true,
"sms": false,
"push_notifications": true
}
},
"updated_at": "2024-03-09T15:30:00Z",
"created_at": "2023-10-01T10:00:00Z"
}
Redis 用户画像数据
如果要在 Redis 中存储用户画像数据,可以采取 缓存化存储 的策略,将用户画像数据以一种 高效且快速检索 的方式存储在 Redis 中。考虑到 Redis 主要是用于 快速访问 和 高并发场景,数据的结构和存储方式要简化且能高效获取。
我们可以利用 Redis 提供的数据结构,如 哈希(Hash)、字符串(String) 和 列表(List) 等来存储用户画像数据。每个用户的画像可以被视为一个独立的对象,存储为 Redis 的 哈希表(Hash),其中字段可以是用户画像的各个属性,确保存储和读取效率。
下面是将上面的完整用户画像数据转化为 Redis 中存储的结构:
假设我们将每个用户的画像数据存储在 Redis 的哈希表中,键名为 user:<user_id>
,哈希字段对应用户画像的不同属性。这样可以将用户的所有信息存储在一个哈希结构下,便于按字段访问。
// 用户画像的 Redis 键名
String userRedisKey = "user:123456";
// 使用 Hash 存储用户画像字段
Map<String, String> userProfile = new HashMap<>();
userProfile.put("user_name", "张三");
userProfile.put("age", "29");
userProfile.put("gender", "male");
userProfile.put("city", "北京");
userProfile.put("province", "北京");
userProfile.put("country", "中国");
userProfile.put("device_os", "Android");
userProfile.put("device_type", "手机");
userProfile.put("device_id", "device12345");
userProfile.put("interests", "科技,数码,篮球,苹果产品");
userProfile.put("loyalty_points", "1200");
userProfile.put("membership_level", "Gold");
userProfile.put("updated_at", "2024-03-09T15:30:00");
userProfile.put("created_at", "2023-10-01T10:00:00");
// 存储浏览历史信息(使用列表或者哈希结构存储)
String browsingHistoryKey = "user:123456:browsing_history";
List<String> browsingHistory = Arrays.asList(
"987654|电子产品|手机|苹果|2024-03-01T10:00:00",
"543210|电子产品|耳机|BOSE|2024-02-25T12:00:00"
);
// 存储购买历史信息(使用列表或者哈希结构存储)
String purchaseHistoryKey = "user:123456:purchase_history";
List<String> purchaseHistory = Arrays.asList(
"123456|家居|厨房用品|150|2024-01-10T10:30:00"
);
// 存储推荐信息(使用哈希或者列表存储)
String recommendationsKey = "user:123456:recommendations";
List<String> recommendations = Arrays.asList(
"987654|电子产品|手机|你最近浏览过苹果手机",
"654321|电子产品|耳机|根据你的购买历史推荐"
);
// 将用户画像存储到 Redis
redisTemplate.opsForHash().putAll(userRedisKey, userProfile);
// 存储浏览历史和购买历史
redisTemplate.opsForList().rightPushAll(browsingHistoryKey, browsingHistory);
redisTemplate.opsForList().rightPushAll(purchaseHistoryKey, purchaseHistory);
redisTemplate.opsForList().rightPushAll(recommendationsKey, recommendations);
Redis 中的存储设计
- 用户基本信息(Hash):
- 键名:
user:<user_id>
- 字段:
user_name
,age
,gender
,city
,device_os
,device_type
等 - 这些字段存储为 Redis 的哈希表,可以通过哈希操作快速获取。
- 键名:
- 用户兴趣(String 或 List):
- 键名:
user:<user_id>:interests
- 字段:将用户兴趣爱好存储为字符串或列表,以便快速获取和修改。
- 键名:
- 用户浏览历史(List 或 Set):
- 键名:
user:<user_id>:browsing_history
- 列表内容:每条浏览历史记录的商品 ID、类别、品牌和浏览时间。
- 使用列表类型进行存储,以便按时间顺序存取浏览历史。
- 键名:
- 用户购买历史(List 或 Set):
- 键名:
user:<user_id>:purchase_history
- 列表内容:每条购买历史记录的商品 ID、类别、金额、购买时间。
- 使用列表类型进行存储,支持用户的购买历史查询。
- 键名:
- 用户推荐信息(List):
- 键名:
user:<user_id>:recommendations
- 列表内容:商品 ID、类别、推荐理由等。
- 使用列表类型存储,可以存储多条推荐商品信息。
- 键名:
- 时间戳(String):
- 存储字段如
updated_at
和created_at
,记录用户画像的更新时间和创建时间。
- 存储字段如
Redis 存储广告数据
基于传统的 静态定向(如地域、设备、性别、年龄段)进行广告缓存,但随着 用户画像 的引入,广告推荐变得更加个性化。这意味着用户行为(例如,最近浏览的商品、购买记录等)将显著影响广告的投放。因此,Redis 和 Elasticsearch 在广告缓存和查询中的作用会发生一些变化。
传统的定向方案
- 缓存广告分类:Redis 可以根据固定的属性(如地域、设备、性别、年龄段等)进行广告分类和缓存。
- 使用场景:当用户的属性较为稳定且广告定向规则较为简单时(例如同一年龄段的用户通常对类似广告感兴趣),可以通过 Redis 直接缓存广告,避免每次都查询 Elasticsearch。
考虑用户画像后的方案
随着用户画像(例如,最近浏览过的商品类型、偏好、历史行为等)的加入,广告推荐的复杂度和个性化需求大大增加。这时,我们可以考虑 动态定向,Redis 的作用可能会有所不同,但依然能在广告推荐中发挥重要作用。
缓存用户行为信息
Redis 依然可以缓存 用户行为数据(例如,最近浏览的商品、最近互动的广告类型、购物车中的商品等)。不过,这些信息会根据时间和用户行为的变化频繁更新。
- 哈希结构:可以使用 Redis 的哈希(
Hash
)数据结构来缓存用户最近的行为数据。- 用户最近浏览的商品:
user:{user_id}:recent_browsed_products
,缓存最近浏览的商品 ID 或商品类别。 - 用户购物车内容:
user:{user_id}:cart
,缓存用户购物车中的商品。
- 用户最近浏览的商品:
这样,当用户访问广告时,Redis 可以快速查询用户的行为数据,以便为其推荐相关广告。
动态广告推荐和缓存
- 定向广告缓存:通过结合 用户画像 和 静态定向条件,Redis 可以缓存一些 用户个性化的广告推荐,但需要注意缓存的 动态性。例如:
- 对于 商品类型广告:如果用户最近浏览了某类商品,那么可以在 Redis 中缓存该类商品的广告。
- 对于 推荐系统:在广告缓存时,可以根据用户的最近行为筛选出最可能感兴趣的广告,并将这些广告缓存到 Redis 中。
user:{user_id}:ads_recommendations
: 存储个性化广告推荐列表。
Redis 与 Elasticsearch 结合
- Redis 缓存最近的个性化广告:当用户的广告需求是基于历史行为、实时兴趣等动态信息时,可以先在 Redis 中快速查找最近的广告推荐。
- Elasticsearch 查询更丰富的广告信息:对于某些需要实时更新或者多维度查询的复杂广告信息,Redis 中的缓存可能无法完全满足需求。这时,可以在 Redis 查询不到结果时,向 Elasticsearch 查询更丰富的数据,例如:按关键词、商品类别、上下文信息等进行深度筛选。
- 广告定向缓存:
- Redis 用于缓存用户的 个性化广告推荐 和 用户行为数据,以提高广告查询的响应速度。
- 用户的基本属性(例如地域、性别、设备类型)仍然可以存储在 Redis 中。
- 动态更新缓存:
- 当用户行为(例如浏览商品)发生变化时,可以动态更新 Redis 缓存,重新计算广告推荐,并缓存更新后的推荐结果。
- 通过定时清理过期的广告缓存,确保广告推荐的实时性和准确性。
- 缺失数据的回退机制:
- 如果 Redis 中没有命中某个广告数据,可以回退到 Elasticsearch 进行复杂的查询和深度筛选,确保最终选择到最适合用户的广告。
广告去重
在分布式集群生产环境下,采用 Redis + 共享 Key 方案,让 Ad Exchange 负责去重,前端兜底,而 DSP 只专注于竞价。
🔥 方案设计
- Ad Exchange 采用 Redis 去重,确保同一广告不会被选中多次。
- 前端兜底去重,防止因网络延迟导致的重复广告。
方案 | 负责去重 | 适用场景 |
---|---|---|
Ad Exchange 端去重(推荐) | Redis 共享去重 | 确保广告位唯一,低延迟,高性能 |
前端兜底去重 | 浏览器前端 | 防止极端情况导致的重复广告 |
🔥 方案优势
方案 | 说明 |
---|---|
按用户 ID 维度去重 | 确保不同用户的广告不互相影响 |
短时间内防止广告重复 | 60 秒 TTL 让广告不频繁重复 |
使用 Redis Set 结构 | 去重高效,查询 O(1) |
✅ 最终方案: Redis + 用户 ID 作为 Key,确保个性化去重,低延迟,高效率! 🚀