
简介基于SpringCloudAlibaba的MySQL广告投放系统源码面向已掌握Java基础、正在学习微服务架构的开发者演示了如何利用Alibaba生态从零搭建一个可运行的广告投放后端。资源包共97个文件其中73个Java业务类覆盖网关、搜索与投放等核心模块配合13个XML映射或配置、3个YAML环境配置、2个SQL初始化脚本以及Maven Wrapper等辅助文件压缩包仅88KB结构紧凑、目录按微服务拆分便于分模块阅读。目前已有98人学习下载适合作为小型微服务项目的入门参考。通过ad-gateway、ad-search、ad-sponsor、ad-common等模块的组织可以理解请求路由、服务拆分、配置管理与MySQL持久化的关键设计两个SQL文件可导入数据库快速还原数据表readme和mvnw命令支撑一键启动便于二次开发。整体源码展示了从网关入口到业务服务再到数据库的完整链路对理解SpringCloudAlibaba组件协作与广告投放场景的落地实现颇有帮助。1. SpringCloudAlibaba MySQL 广告投放系统一套能跑通投放全链路的中型微服务源码广告投放系统是典型的“配置要实时生效、投放要扛住量、数据要落得住”的业务和纯 CRUD 后台完全是两码事。这份基于 SpringCloudAlibaba MySQL 的广告投放系统源码把 Nacos 注册与配置中心、Sentinel 流量控制、Gateway 网关、MySQL 业务库表以及投放记录异步落库完整串了起来微服务该有的组件一个不少但代码量控制在能读懂、能改的范围内。适合正在从单体 Spring Boot 往 Spring Cloud Alibaba 迁移的人也适合需要一套广告计划、创意、定向、投放、报表闭环来二次开发的从业者。我拆完第一印象是这套源码不像教学 demo更像按真实投放链路整理的工程。2. 服务拆分与工程结构广告投放链路按什么逻辑切成微服务2.1 拆分思路按投放动作拆不按数据表拆先看这套源码的服务划分。它没有把一张表拆成一个服务而是按广告投放的实际动作链路拆成几个业务服务ad-gateway 做统一入口和路由转发ad-admin 管广告主、账号、权限ad-campaign 管广告计划与创意、投放策略ad-delivery 做投放执行接收流量请求完成计划匹配、频控、预算扣减ad-report 做投放数据统计。这是微服务拆分里“按业务能力拆分”的落地版。广告投放系统的业务闭环是广告主创建计划 → 设置创意 → 投放执行时通过路由匹配计划 → 记录曝光点击 → 报表汇总。如果按表拆服务一个计划从创建到投放要跨四五个服务事务和一致性根本兜不住按动作拆一个业务动作尽量落在一个服务内部完成跨服务调用只有必要的那几处整体链路短出问题也好定位。这里值得注意的细节是 ad-delivery 的定位。投放执行是广告系统里对延迟最敏感的一环流量请求过来要做计划匹配、预算判断、频控判断、创意决定每一步都要求快。源码把它独立成服务并在服务内部用本地内存缓存了投放中的计划快照避免每次请求都查 MySQL。这个设计在拆服务时就决定了不是后来补的说明作者对投放链路的性能瓶颈有清楚认知。拆分粒度上源码没有把创意图存储、反作弊、财务结算再拆出去而是收在现有服务内部。我的看法是对一份用于学习和二次开发的源码这个粒度最合适。拆太细事务边界和部署成本会淹没主线拆太粗又看不到微服务的协作方式。ad-report 单独成服务也有讲究——投放数据和报表查询是两类负载明细表写库频繁、查询端聚合重混在一起容易互相拖垮。2.2 Nacos注册中心与配置中心怎么落地这套源码用 Nacos 同时做注册中心和配置中心。每个服务启动时先连 Nacos 把自己注册进去ad-gateway 通过 Nacos 做服务发现把请求路由到对应实例。Nacos 的配置方式值得照抄服务端 bootstrap.yml 长这样spring: application: name: ad-campaign cloud: nacos: server-addr: 192.168.1.10:8848 username: nacos password: nacos namespace: ad-prod group: AD_GROUP config: file-extension: yml shared-configs: ->CREATE TABLE ad_campaign ( id bigint(20) NOT NULL AUTO_INCREMENT, advertiser_id bigint(20) NOT NULL COMMENT 广告主ID, name varchar(128) NOT NULL COMMENT 计划名称, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0草稿 1待审核 2投放中 3暂停 4终止, daily_budget decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 日预算, total_budget decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT 总预算, start_time datetime DEFAULT NULL COMMENT 投放开始时间, end_time datetime DEFAULT NULL COMMENT 投放结束时间, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_advertiser_status (advertiser_id,status), KEY idx_status_endtime (status,end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT广告计划表;这份 DDL 里有两个点值得说明。第一个是 idx_advertiser_status支撑广告主后台“按状态筛选计划”的高频查询。status 区分度不高但前缀 advertiser_id 让索引依然有选择性可以快速收敛到某个广告主的数据范围。第二个是 idx_status_endtime这是给投放引擎用的投放引擎每秒要筛选“状态投放中且 end_time 现在”的计划status 和 end_time 的组合让这个扫描走索引而不是全表投放高峰期的查询性能差别就在这里。字段类型上预算用 decimal(12,2)时间用 datetime 而不是时间戳。datetime 存的是可读时间排查数据时一眼能看懂跨时区问题由业务层统一处理比 timestamp 在时区转换上出的幺蛾子少。tinyint 状态字段的注释写清楚这是给后来接手的同事留的活路不然没人知道 2 代表什么。3.2 创建广告计划的 Service 实现有了表看创建计划的核心代码。这套源码创建计划用的 Service 实现大致是这样Service public class CampaignServiceImpl extends ServiceImplCampaignMapper, AdCampaign implements CampaignService { Override Transactional(rollbackFor Exception.class) public Long createCampaign(CampaignCreateRequest request) { // 预算必须大于0投放时间必须合法 if (request.getDailyBudget().compareTo(BigDecimal.ZERO) 0 || request.getTotalBudget().compareTo(request.getDailyBudget()) 0) { throw new BizException(ErrorCode.PARAM_INVALID, 预算参数不合法); } if (request.getStartTime().isAfter(request.getEndTime())) { throw new BizException(ErrorCode.PARAM_INVALID, 投放结束时间必须晚于开始时间); } // 新计划进入待审核状态不直接投放 AdCampaign campaign new AdCampaign(); campaign.setAdvertiserId(request.getAdvertiserId()); campaign.setName(request.getName()); campaign.setStatus(StatusEnum.PENDING_REVIEW.getCode()); campaign.setDailyBudget(request.getDailyBudget()); campaign.setTotalBudget(request.getTotalBudget()); campaign.setStartTime(request.getStartTime()); campaign.setEndTime(request.getEndTime()); this.save(campaign); // 创建完计划后初始化日预算消耗记录 campaignBudgetService.initDailyBudget(campaign.getId(), campaign.getDailyBudget()); return campaign.getId(); } }逻辑分三段第一段做参数校验预算和时间先兜底第二段组装实体状态固定为待审核不走“创建即投放”的路子第三段调用 campaignBudgetService 初始化日预算账本这是后续投放扣费时对账的起点。这里最值得抄的是 Transactional(rollbackFor Exception.class)。默认情况下 Spring 事务只回滚 RuntimeException如果业务代码里抛了受检异常事务不会回滚预算账本初始化和计划保存就会出现一个成、一个废的状态。显式指定 rollbackFor Exception.class 是微服务项目里的规范写法宁可事务回滚多一点不能出现半截数据。另外注意 BigDecimal 比较用的是 compareTo 而不是 equals因为 equals 会比较小数位精度1.0 和 1.00 在 equals 下不相等这是金额比较最常见的坑。3.3 参数约定与扩展点分页查询怎么接计划创建完运营后台要按条件分页查计划。这套源码里用 MyBatis-Plus 的 QueryWrapper 做动态条件拼接Override public PageResultCampaignVO pageCampaigns(CampaignPageQuery query) { LambdaQueryWrapperAdCampaign wrapper new LambdaQueryWrapper(); wrapper.eq(Objects.nonNull(query.getAdvertiserId()), AdCampaign::getAdvertiserId, query.getAdvertiserId()); wrapper.eq(Objects.nonNull(query.getStatus()), AdCampaign::getStatus, query.getStatus()); wrapper.ge(Objects.nonNull(query.getStartTime()), AdCampaign::getStartTime, query.getStartTime()); wrapper.le(Objects.nonNull(query.getEndTime()), AdCampaign::getEndTime, query.getEndTime()); wrapper.orderByDesc(AdCampaign::getCreatedAt); PageAdCampaign page this.page( new Page(query.getPageNum(), query.getPageSize()), wrapper); // 转VO、填充广告主名称省略 return PageResult.of(page.getTotal(), voList); }这段代码的要点是条件动态拼接每个 eq/ge/le 的第一个布尔参数是“参数不为空才拼”这是分页查询接口最常用的模式避免空条件导致查出无关数据。注意 orderByDesc(AdCampaign::getCreatedAt) 一定要加在动态条件之后并且保持稳定否则 MySQL 在深分页时会不稳定地走不同的执行计划出现同样的 SQL 时快时慢的玄学问题。分页参数的边界应该在 Controller 层就约束死pageNum 从 1 开始pageSize 最大 200。这套源码的查询接口没有把超大页码转成游标如果你的数据量到百万级把 orderBy 字段改成 id配合 where id lastId 的写法这是深分页最常见的优化方向。日常运营后台的分页量级用 LIMIT 问题不大但接口要做好 pageSize 上限防止有人一次拉全表。4. 投放执行链路Sentinel 限流、异步落库与预算扣减的配合4.1 Sentinel 限流广告流量入口的 QPS 控制投放请求进来先过限流。这套源码在 ad-gateway 上配置了 Sentinel 规则针对投放接口按 QPS 兜底spring: cloud: sentinel: transport: dashboard: 192.168.1.10:8858 datasource: flow: nacos: server-addr: 192.168.1.10:8848 >[ { resource: POST:/delivery/request, grade: 1, count: 200, controlBehavior: 0, strategy: 0 } ]参数含义resource 是保护的资源按“请求方法 路径”粒度grade1 表示按 QPS 限流grade0 是按并发线程数count200 是单机 QPS 阈值controlBehavior0 是直接拒绝1 是预热2 是排队等待。广告投放场景下单机 200 QPS 直接拒绝是合理的——投放请求不值得排队等丢了这次流量广告主看的是整体效果不是单次请求的成败。Sentinel 的坑在于规则从 Nacos 加载后如果直接改 Nacos 上的 JSON需要服务端版本支持自动推送本地开发时如果 Nacos 连不上Sentinel 会静默降级成无规则状态服务照常启动但完全不限流。调试限流逻辑时先确认规则真的加载了在 Sentinel 控制台的“流控规则”页能看到。看不到规则就排查 Nacos 连接这是最容易翻车的点。4.2 投放明细异步落库线程池与批量插入投放记录是读写比最悬殊的表写是主路径读是后台统计。这套源码把投放明细的写入完全异步化投放接口只做计划匹配、预算校验和扣减然后把明细丢进线程池批量落库。线程池定义如下Bean(deliveryExecutor) public ThreadPoolTaskExecutor deliveryExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(2000); executor.setThreadNamePrefix(delivery-record-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy()); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); return executor; }参数解释corePoolSize4 是常驻线程queueCapacity2000 是缓冲队列DiscardOldestPolicy 是队列满了之后丢弃最老的任务——对投放明细这种允许少量丢失的日志型数据丢弃最老的任务比拒绝新任务更合适新数据比旧数据更有价值。最后一组参数是优雅停机应用关闭时等线程池里的任务执行完最多等 30 秒避免停机瞬间丢一批明细。批量落库的方法用 Async 标注由 Spring 代理执行Async(deliveryExecutor) public void saveDeliveryRecords(ListDeliveryRecord records) { if (records null || records.isEmpty()) { return; } // 每批500条用MySQL扩展语法批量插入 deliveryRecordMapper.batchInsert(records); }批量插入的 XML 里就是常见的 foreach 拼接 INSERT 语句单条 INSERT 包多组 VALUES能显著减少网络往返和日志刷盘次数。批量大小 500 是个平衡值太小批量优势出不来太大单条 SQL 体积过大可能触达 MySQL 的 max_allowed_packet 上限报错时注意看这个参数。异步落库意味着投放系统的主链路不依赖 MySQL 的写入性能MySQL 慢或者锁了不影响投放接口的响应。代价是投放明细有一定延迟报表模块设计时按“分钟级延迟”来容忍。这套源码在投放明细表上加了 created_at 索引做日报表聚合时避开高峰期的写入窗口把聚合延迟控制在分钟级。4.3 预算扣减用条件更新替代先查后写预算扣减是广告投放系统里最容易出数据问题的点。投放请求并发高同一计划的预算可能被十几个线程同时扣减。如果按先 SELECT 余额、比较、再 UPDATE 的方式写必然超扣。MySQL 锁按粒度分有表锁和行锁按模式分有共享锁和排他锁这套源码里用到的就是 InnoDB 的行锁。正确做法是条件更新UPDATE ad_campaign SET daily_spent daily_spent #{cost} WHERE id #{campaignId} AND status 2 AND daily_spent #{cost} daily_budget这样写法的关键是把“余额是否够”的判断直接放进 UPDATE 的 WHERE 条件里由 MySQL 的行锁保证同一行同一时刻只有一个更新成功。affected rows 为 0 就说明余额不足业务层返回“预算耗尽”投放接口不再继续出广告。这比 SELECT FOR UPDATE 省一次锁等待也不用考虑事务里锁什么时候释放。MySQL 走的是 InnoDB 行锁id 是主键条件更新锁住的就是这一行对其他广告计划的更新没有影响。但如果 WHERE 条件没走索引比如漏了 id 条件导致全表扫描InnoDB 会退化成锁表典型现象就是更新特别慢、并发一高就死锁。出现死锁报警时第一件事是 EXPLAIN 这条 UPDATE 语句看 type 字段是不是 range 或 ref同时检查 MySQL 事务隔离级别和间隙锁的影响。还有一点status2投放中这个条件放在 WHERE 里保证已经暂停或终止的计划不会被扣预算。这个条件在更新语句里必须带业务代码里先查询校验状态再更新中间状态可能已经被并发改掉了查出来的状态不可信。投放系统并发场景下所有状态判断都要跟着写操作走不能只靠读操作时的快照。5. 广告投放系统常见问题排查五个环境与数据上的高频事故5.1 微服务起不来Nacos 地址配错bootstrap.yml 根本没加载现象ad-campaign 服务一启动就报 ConnectException提示连接 192.168.1.10:8848 失败。检查发现 Nacos 正常在跑网络也能通但服务就是注册不上去。原因通常是两件事叠加。第一bootstrap.yml 没有被加载。Spring Boot 2.4 之后默认不再自动加载 bootstrap 上下文必须显式引入 spring-cloud-starter-bootstrap 依赖才能读到 bootstrap 文件里的 Nacos 配置。第二Nacos 地址写成了 localhost——服务在容器里而 Nacos 在宿主机容器里的 localhost 指向容器本身不是宿主机。解决确认 pom 里有 spring-cloud-starter-bootstrap 依赖没有就加上容器环境把 Nacos 地址改成宿主机的局域网 IP不要写 localhost。改完以后在启动日志里搜 “Nacos registry” 确认注册成功别只看服务没报错就继续往下走。这套源码里 Nacos 相关配置都在 bootstrap.yml拆出来单独启动某个服务时记得把 bootstrap.yml 放在 resources 根目录放错位置配置不会被加载服务会以无注册中心状态启动网关路由全部 404。5.2 MySQL 5.7 和 8.0 驱动不兼容本地能跑服务器跑不了现象本地 MySQL 5.7 一切正常代码部署到服务器MySQL 8.0之后数据源报错连不上。原因MySQL 8.0 的 JDBC 驱动类从 com.mysql.jdbc.Driver 换成了 com.mysql.cj.jdbc.Driver旧驱动类在 8.0 下会直接报 ClassNotFound 或者被驱动内部拒绝而且 8.0 默认要求 SSL 连接没在 URL 里加 useSSLfalse 会报 SSL 握手失败。MySQL 安装教程网上到处是但项目里真正卡人的是驱动和时区参数这一步接不上后面全是白搭。解决MySQL 8.0 环境统一使用 mysql-connector-java 8.x 驱动驱动类写 com.mysql.cj.jdbc.DriverJDBC URL 追加 ?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue。allowPublicKeyRetrievaltrue 是 8.0 用 caching_sha2_password 认证时必需的不加会报 Public Key Retrieval is not allowed。serverTimezone 不设置的话驱动会取服务器默认时区而容器默认是 UTCMySQL 里存的时间会整体差 8 小时按天分组的报表数据全部错位——这条我是花了一下午对数据才发现的时间。5.3 预算被扣成负数先查后写导致并发超扣现象高并发投放压测时计划的 daily_spent 超过了 daily_budget甚至出现负数。原因扣减逻辑写成了先 SELECT daily_spent在 Java 代码里比较剩余预算再 UPDATE。两个线程同时 SELECT 拿到同一个余额都认为预算足够都去 UPDATE最终金额超扣。这是典型的丢失更新问题本质上是把并发控制放在应用层而不是数据库层。解决改成条件更新 UPDATE ... SET daily_spent daily_spent #{cost} WHERE id #{id} AND daily_spent #{cost} daily_budget用 affected rows 判断扣减是否成功。我接手这套源码时把所有预算扣减都改成了这个写法不只是计划的日预算创意维度、广告主维度的预算账户同样处理因为它们是同一类并发问题。改完之后超扣现象从压测必现变成了零复现。5.4 Docker 部署 MySQL容器重启后投放数据全没了现象服务器重启后广告主账号和投放数据全部消失MySQL 里只剩默认库。原因docker run 启动 MySQL 时没有挂载数据卷数据写在容器可写层。容器重启时数据还在但容器一旦被删除重建、或者镜像升级时忘了保留容器数据就跟容器一起没了。dokcer 安装 MySQL 失败的报告里数据丢失是最高频的一类根子都在这个挂载上。解决启动时显式挂载数据卷docker run -d --name mysql-ad \ -v /data/mysql:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORDxxxx \ -p 3306:3306 \ --restartalways \ mysql:5.7同时建议加上 --restartalways避免宿主机重启后 MySQL 不自动拉起广告投放服务一启动就往一个不存在的库写数据启动报错一片红。排查手法数据文件在备份目录里找得到但容器里 mysql 数据目录是干净的基本就是挂载没设对。Docker 环境下 MySQL 数据恢复没有后悔药从第一次部署起就要把数据卷的路径写进部署脚本不要靠命令行手动敲。5.5 报表查询慢函数包裹日期字段导致索引失效现象ad_delivery_record 表数据量到几百万行后按天查曝光点击的报表 SQL 要八九秒。原因报表查询条件写成了 DATE(created_at) ?DATE 函数把 created_at 字段包起来后MySQL 无法使用 created_at 上的索引变成全表扫描。另一个常见诱因是关联字段类型不一致campaign_id 在计划表是 bigint在明细表是 varcharJOIN 时发生隐式转换索引照样失效。这两个问题都属于“MySQL 索引失效的经典场合”。解决日期范围查询改成 created_at ? AND created_at ?把时间边界的计算放到 Java 代码里让索引能够被直接使用。索引方面ad_delivery_record 的索引已经是 (campaign_id, created_at)查询时先按 campaign_id 过滤再按时间范围过滤这个联合索引就能吃上。字段类型不一致是表设计阶段埋的雷发现后立刻统一类型用 ALTER TABLE 改掉别拖。这几条坑有个共同点表面上是“环境问题”或“MySQL 玄学”根子都在配置或 SQL 写法上。排查时先动 EXPLAIN 和手敲 SQL 验证别上来就怀疑框架出 Bug——Spring Cloud Alibaba 和 MyBatis 的源码我也翻过绝大部分线上问题到最后都是配置和 SQL 层面的低级失误。6. 报表聚合表把推送日报从 10 秒压到 200 毫秒的实操投放系统的报表查询是典型的明细重聚合场景。ad_delivery_record 一直在写入查询端又要在它上面做按天、按计划、按创意维度的分组统计。明细表上即便建了索引分组聚合的代价依然随数据量线性增长到几百万行以后日报接口慢到没法用。我接手这套源码时报表接口还是直接查明细表第一步就是加聚合表。ad_report_daily 表结构里最关键的约束是一个唯一索引CREATE TABLE ad_report_daily ( id bigint(20) NOT NULL AUTO_INCREMENT, campaign_id bigint(20) NOT NULL, stat_date date NOT NULL, impressions int(11) NOT NULL DEFAULT 0, clicks int(11) NOT NULL DEFAULT 0, cost decimal(12,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (id), UNIQUE KEY uk_campaign_date (campaign_id, stat_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT广告投放日报聚合表;唯一索引 (campaign_id, stat_date) 配合 INSERT ... ON DUPLICATE KEY UPDATE 做增量合并定时任务每分钟拉取上一分钟的明细做聚合INSERT INTO ad_report_daily (campaign_id, stat_date, impressions, clicks, cost) SELECT campaign_id, DATE(created_at) AS stat_date, COUNT(*) AS impressions, SUM(click_flag) AS clicks, SUM(cost) AS cost FROM ad_delivery_record WHERE created_at #{lastBatchTime} AND created_at #{nowBatchTime} GROUP BY campaign_id, DATE(created_at) ON DUPLICATE KEY UPDATE impressions VALUES(impressions), clicks VALUES(clicks), cost VALUES(cost);两个关键参数lastBatchTime 和 nowBatchTime 分别记录上次批处理时间和当前批处理时间。注意 WHERE 条件里不能写成 DATE(created_at) ?那样 created_at 的索引会失效增量扫描退化成全表扫描定时任务会越跑越慢。正确姿势是用 created_at 的范围条件DATE_FORMAT 只出现在 SELECT 的分组表达式里不影响扫描范围。验证方法改完后对同一天的数据分别查明细聚合和聚合表先比结果再比性能。EXPLAIN 看聚合表查询是 const 或 ref明细表查询是 ALL 或 range性能对比从秒级到毫秒级。MySQL 存储过程做报表聚合我也试过但定时任务加业务代码更直观出问题单条 SQL 就能复盘。从那以后我每次做报表模块都强制走一遍“先出聚合表、再验证一致性、最后 EXPLAIN 看执行计划”的流程这套规律用在任何带日报统计的系统里都能把查询从秒级拉到毫秒级。希望帮到你。本文还有配套的精品资源点击获取