简介:这是一份广告投放系统的微服务源码,基于Spring Cloud Alibaba技术栈构建,适合正在学习微服务架构、需要参考真实业务代码的Java后端开发者。整个项目按业务职责拆分为网关服务、搜索服务、广告主服务、公共服务等多个模块,并配有基础数据库初始化脚本,能够直观呈现请求从网关进入、服务注册与发现、配置统一管理到MySQL数据读写这一完整过程。压缩包共97个文件,主体为73个Java源文件,另有XML和YAML配置文件用于设置服务参数,SQL脚本负责初始化表结构,同时包含Maven构建相关的辅助文件;压缩包整体只有88KB,属于轻量级纯代码资料,便于快速下载查阅。目前已有98人学习该资源,适合用于课程设计、毕业设计或技术调研。对Spring Cloud Alibaba的实践感兴趣的话,可以从网关模块的过滤与路由入手,逐步扩展到搜索和广告投放的核心逻辑,理解微服务环境下的服务治理、容错处理以及领域模型设计方式,结合SQL脚本还能反推业务表结构,是一份完整度较高的工程参考。
1. 为什么SpringCloudAlibaba + MySQL的广告投放系统源码值得打开
按SpringCloudAlibaba+MySQL的组合来做广告投放系统,不只是把几个微服务组件堆在一起。这个源码包真正解决的是广告业务里最敏感的几件事:账户余额不能算错、预算不能超扣、计划状态要实时切换、每日消耗报表要对得上账。很多后端开发者拿到这种源码包时第一反应是解压、配数据库、启动Nacos、跑起来看页面,但我建议先花十分钟理解这个系统的边界——它在MySQL上建了哪些表,哪些接口被Sentinel保护着,哪条链路是最终一致而不是强一致。这套系统适合两类人:一类是刚学完SpringCloudAlibaba课程、想找一个真实业务练手的工程师;另一类是准备把微服务项目写进简历,需要一个讲得清架构和踩坑经历的求职者。源码的价值不在压缩包大小,在于你能不能让它替你说话。
2. SpringCloudAlibaba在广告投放系统里的角色:服务治理与业务保护
2.1 Nacos为何成为这套微服务的地基:注册发现与配置管理二合一
广告投放系统的微服务不是摆样子。以最简工程为例,网关、账户、投放、报表四个服务通过Nacos互相发现:账户服务改了预算,投放服务需要实时感知账户状态;报表服务要定时拉取投放服务的数据。如果服务地址写死在配置文件里,每次改动都要重启,这在广告投放的运营节奏里没法接受——账户状态变化、预算调整是全天候高频发生的。Nacos把注册中心与配置中心合并到一个进程里,本地跑源码时少部署一个组件,这也是SpringCloudAlibaba这套栈的吸引力所在。
在配置中心层面,广告投放系统的典型需求是:预算阈值、投放时段规则、创意审核开关要能动态调整。运营人员通过控制台改配置后,服务无需重启即可生效。这就带来一个要求:业务代码里要用@RefreshScope注解标记允许动态刷新的Bean。如果源码里看到配置类是加了@RefreshScope的,那么改动Nacos配置后立即生效;如果没有加,那么实际生效可能需要重启,这是排查动态配置不生效时的第一思路。
Nacos接入一般写在bootstrap.yml里,关键配置段长这样:
spring: application: name: ad-delivery-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 file-extension: yml group: AD_GROUPdiscovery段管的服务注册,config段管的配置拉取。file-extension: yml决定了服务启动后会去Nacos上找ad-delivery-service.yml这个dataId,group: AD_GROUP是配置分组,广告系统里按业务域分租能避免投放配置和报表配置互相覆盖。启动时报NacosException: java.net.ConnectException,十有八九是server-addr写错或Nacos没起来;如果微服务跑在Docker容器里,127.0.0.1指向的是容器自己,要改成宿主机IP或host.docker.internal。
2.2 Sentinel在投放链路中的位置:流量洪峰与预算的第一道防线
广告投放系统的流量模型和电商秒杀不同,它的峰值经常来自“一个爆款创意被大量用户在同一时段触发”。如果投放决策接口不做流量保护,瞬间高并发会直接压垮MySQL查询,更严重的是预算扣减在大事务里排队,数据库锁等待迅速上涨。Sentinel在这条链路里承担两层工作:一是对投放决策接口做QPS限流,超过阈值快速失败并返回降级结果;二是配合熔断规则,当下游广告计划服务异常率升高时,自动断开调用,避免故障扩散。
在源码里Sentinel常见接入方式有两种:一种是用@SentinelResource注解手工定义资源点,另一种是在Sentinel控制台配置规则。本地跑源码时,如果不想额外启动控制台,最省事的是在代码里硬编码规则,但业务代码里的资源名必须和规则里的resource值完全一致,否则规则不生效。投放决策接口的典型写法:
@PostMapping("/ad/decision") @SentinelResource( value = "ad_decision", blockHandler = "onBlocked", fallback = "onFallback" ) public AdDecisionResult getAdDecision(@RequestBody AdRequest request) { // 核心定向逻辑:读MySQL广告计划表,筛选匹配创意并返回 return deliveryService.matchPlan(request); } public AdDecisionResult onBlocked(AdRequest request, BlockException e) { // 限流后的兜底返回,RPC调用方按降级结果处理,不计费 return AdDecisionResult.bypass(request.getTraceId()); }blockHandler与fallback的区别要注意:blockHandler处理的是Sentinel判定为限流或熔断的异常,fallback处理的是业务方法本身抛出的异常。广告投放系统里onBlocked返回的bypass结果会带上traceId,方便报表侧识别这是“被限流”而不是“真实曝光”,不然统计点击率时会把限流数据混进去,报表就不准了。这个细节直接决定对账能不能平。
2.3 微服务怎么拆才不虚:账户、投放、报表三域的服务边界
广告投放系统的服务划分不能只看模块数量,核心标准是每个服务是否有独立的MySQL数据域。常见且可靠的做法是拆成四个服务:ad-gateway统一网关、ad-auth做登录鉴权、ad-account管账户与预算、ad-plan管投放计划与定向、ad-report管点击与消费报表。如果源码比这个拆得更细,比如单独拆了ad-creative创意服务,也正常。关键在于不能出现多个服务直连同一张表的情况。
数据域与服务归属对照:
| 数据域 | 归属服务 | 核心表 |
|---|---|---|
| 账户域 | ad-account | ad_account、ad_account_budget |
| 投放域 | ad-plan | ad_plan、ad_creative、ad_targeting |
| 报表域 | ad-report | ad_click_log、ad_report_day |
广告投放系统里最典型的坏味道是报表服务直接查账户服务的表,导致账户表被多服务读写,事务边界逐渐失效。正确做法是服务只读写自己所属数据域的MySQL表,跨域数据通过接口调用或数据同步获得。报表服务需要账户名做展示时,可以通过账户服务提供的一个只读接口去查,而不是直连账户库。这样拆出来的微服务才有意义,否则只是把单机应用拆成了远程调用的分布式单体。
3. 核心落点:广告投放系统的MySQL表设计与事务边界
3.1 账户与预算表:用DECIMAL与条件更新解决超扣难题
广告账户表看起来简单,但设计差一点后面全是坑。余额字段不要用FLOAT或DOUBLE,点击计费是0.01元精度,浮点累计在千万级数据上会出现精度误差,对账时差几分钱查一整夜。正确做法是用DECIMAL(14,2),14位精度足够支撑亿级金额,2位小数贴合人民币分。预算表与账户余额表要分开,预算表记录当天可用总量,余额表记录账户充值总额,两者职责不能混。
CREATE TABLE ad_account ( account_id BIGINT PRIMARY KEY AUTO_INCREMENT, account_name VARCHAR(64) NOT NULL, balance DECIMAL(14,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE ad_account_budget ( budget_id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, daily_budget DECIMAL(14,2) NOT NULL DEFAULT 0.00, used_amount DECIMAL(14,2) NOT NULL DEFAULT 0.00, budget_date DATE NOT NULL, UNIQUE KEY uk_account_date (account_id, budget_date) );uk_account_date唯一键保证了同一账户同一天只有一条预算记录,否则并发写入时会出现两条预算记录,扣减时不知道按哪条扣。预算扣减不能用“先select再update”的两步走,这是并发超扣的根源。我一般直接让更新语句带上余额判断:
UPDATE ad_account_budget SET used_amount = used_amount + #{cost}, updated_at = NOW() WHERE account_id = #{accountId} AND budget_date = #{budgetDate} AND used_amount + #{cost} <= daily_budget;这条语句依赖MySQL的行锁保证并发安全:多个请求同时执行时,只有一条能进入更新,affected rows为0就说明预算不足,服务端要返回预算耗尽,不能再给该广告派发流量。如果你在源码里看到的是应用层加锁或者同步块,那就要警惕,它在单实例下能用,一部署多实例就失效。MySQL的REPEATABLE READ隔离级别下,这种条件更新依然有效,因为行锁是在更新时获取的,与普通SELECT不同。
3.2 计划、创意、定向三张表的结构与索引设计
投放计划表(ad_plan)记录计划ID、账户ID、计划名称、日预算、投放时段、状态。创意表(ad_creative)记录标题、图片URL、跳转链接。定向表(ad_targeting)存放地域、性别、年龄段、兴趣标签。三张表通过plan_id关联。定向条件如果拆成独立行,每行一个维度值,就用plan_id + targeting_type做联合索引;如果定向条件以JSON格式存在创意表里,展示方便但筛选效率差。广告投放系统在查询时常有“按计划状态+投放日期筛选匹配创意”的场景,索引必须优先覆盖这个组合。
CREATE TABLE ad_plan ( plan_id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, plan_name VARCHAR(128) NOT NULL, daily_budget DECIMAL(14,2) NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, status TINYINT NOT NULL DEFAULT 0, KEY idx_account_status (account_id, status), KEY idx_date_range (start_date, end_date) );idx_account_status用于“某账户下所有启用计划”的查询,这对广告主后台列表页很关键;idx_date_range用于按投放日期范围筛选计划,要注意如果查询条件是WHERE start_date <= ? AND end_date >= ?,这个索引的左侧列只能用到start_date。报表侧的排序语句常写成ORDER BY cost DESC,这种消耗榜排序不应该在应用层内存里做,要让MySQL走覆盖索引或者在报表表上建(stat_date, cost)索引。
3.3 对账与最终一致性:本地事务之外的事后兜底
广告投放是流水型业务,不能指望本地事务解决一切。曝光明细写入与预算扣减在同一个服务里时,可以用一个本地事务包住;一旦服务拆分,就必然要面对“明细写了但预算没扣”或“预算扣了但明细没落库”的中间状态。正确处理方式不是引入复杂的分布式事务框架,而是接受最终一致,用对账任务兜底。我接手过的广告类系统,线上事故十有八九不在代码逻辑,而在没人跑对账。
常见的对账做法:每天凌晨跑一个定时任务,把曝光明细表的SUM(price)和账户预算表的used_amount做对比,差值不为0的账户写入异常表等待人工核查。报表聚合查询是另一个对账支点:
SELECT plan_id, SUM(cost) AS total_cost, COUNT(*) AS click_cnt FROM ad_click_log WHERE stat_date = '2025-07-14' AND account_id = 1 GROUP BY plan_id ORDER BY total_cost DESC;这个查询在MySQL里的执行瓶颈一般是ad_click_log表太大,没有按日期裁剪索引。建议加一个(account_id, stat_date, plan_id, cost)的覆盖索引,让WHERE和GROUP BY都走上索引,排序就不会触发临时文件。如果源码里建了这张表但没建覆盖索引,报表越查越慢是必然的。
4. 本地跑通这套源码:从MySQL建库到服务按序启动
4.1 环境准备:MySQL 5.7/8.0的安装与初始化数据
先把基础环境理顺:JDK、MySQL、Nacos三个版本必须匹配。JDK8与SpringCloudAlibaba 2.2.x是经典组合,换JDK11或17时要注意依赖里是否引入了javax包缺失问题。MySQL的安装教程很多,本地开发机最省事的是用Docker起一个实例,镜像和端口都由你自己控制,卸载也干净:
docker run -d \ --name ad-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root \ -e MYSQL_DATABASE=ad_system \ mysql:5.7 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_ci这里的--character-set-server=utf8mb4很关键,docker安装MySQL失败十次里有八次是忘记指定字符集,或者宿主机3306端口被别的实例占着。启动后用docker logs ad-mysql看报错,如果一直反复重启,基本就是端口冲突或密码策略问题。Windows本机不想用Docker的话,也可以走免安装版zip包:解压后配置my.ini的basedir和datadir,执行mysqld --initialize-insecure初始化,再mysqld --install ad-mysql安装服务,net start ad-mysql启动。
mysqld --initialize-insecure mysqld --install ad-mysql net start ad-mysql--initialize-insecure表示初始化时root账号为空密码,适合本地开发。初始化完成后,用mysql -uroot -p进命令行,执行建库脚本。源码里一般会带sql/目录的初始脚本,如果没有,就手动建ad_system库,再按第三章的表结构执行。进入MySQL后可以先用一个常用命令确认状态:SHOW VARIABLES LIKE 'transaction_isolation';,广告投放系统的对账要求下,默认的REPEATABLE READ就行。
4.2 Nacos单机部署与配置导入:这一个坑最耽误时间
Nacos要从官网下载对应版本,解压后进入bin目录执行启动命令。2.x版本本地单机运行时,-m standalone这个参数必须带,否则即使在一台机器上它也会尝试以集群模式启动,日志里报The server IP list of Nacos is empty,看过这段日志的人不在少数。
cd nacos/bin # Linux / macOS sh startup.sh -m standalone # Windows startup.cmd -m standalone启动完成后访问http://localhost:8848/nacos,默认账号密码都是nacos/nacos。注意Nacos 2.x的鉴权默认开启,登录后才能看到配置列表。广告投放系统源码里的配置一般以{服务名}.yml形式存放在Nacos控制台,比如ad-delivery-service.yml,内容里会包含MySQL连接串、Redis地址、日志级别等。如果源码包里带config/或nacos_init/目录,可以在控制台手动新建配置,把内容粘进去,dataId写成服务名.yml,group写成AD_GROUP,与2.1节里的配置段对应。
4.3 服务启动顺序与网关连通性验证
不按依赖顺序启动,在Nacos还没完全注册好时,后启动的服务会报“找不到服务实例”。我一般按这个顺序启动:ad-auth(基础服务)→ad-account→ad-plan→ad-report→ad-gateway。网关最后启动,这样它启动时能拿到完整的服务路由列表。每个服务启动后看日志里有没有register finished之类的关键词,再到Nacos控制台的服务列表确认健康实例数。
本地同时跑多个服务时,端口容易冲突。各服务默认端口在application.yml里定义,可以用--server.port参数临时覆盖。例如ad-plan默认监听8082,如果你本地8082被占用,启动时加--server.port=8092。验证整条链路是否打通,最直接的办法是走网关打一次投放决策接口:
curl -X POST http://127.0.0.1:8080/ad/decision \ -H "Content-Type: application/json" \ -d '{"accountId":1,"scene":"homepage","deviceId":"test-device-001"}'如果返回JSON里包含traceId和广告计划内容,说明网关路由到了ad-plan服务,并且该服务成功查询了MySQL并返回了数据。如果返回的是bypass结果,先查一下Sentinel是不是把这条请求限流了,再查ad-plan服务日志。
5. 常见翻车与排查记录:Nacos、MySQL与并发扣减的真实问题
5.1 服务启动后Nacos注册不上:NacosException与单机模式
现象:ad-gateway启动日志连续打印Connection refused,Nacos控制台服务列表为空。
原因:最常见的是Nacos没有以单机模式启动,服务端起不来;其次是微服务与Nacos网络不通,比如服务在Docker容器里,server-addr写成127.0.0.1指向容器自身。SpringCloudAlibaba 2.2.x对应Nacos 1.4.x,如果把服务端换成Nacos 2.x,客户端旧版本可能心跳上报异常,注册上了但过一会儿就掉线。
解决:先确认Nacos控制台能打开,再确认启动参数带-m standalone;容器内的服务把server-addr改成宿主机IP或host.docker.internal;最后核对SpringCloudAlibaba与Nacos的版本矩阵,不要混用差两个大版本的组合。
5.2 MySQL连接失败:时区、SSL与驱动版本
现象:应用启动时报Communications link failure,或者Unknown time zone 'Asia/Shanghai'。
原因:JDBC连接串没有指定serverTimezone,MySQL 8.0默认启用SSL,旧版本mysql-connector-java驱动握手失败。还有一种情况:本地装了MySQL 8.4,但源码里驱动还是5.x风格,直接连不上。
解决:在Nacos配置里把连接串写成这样:jdbc:mysql://127.0.0.1:3306/ad_system?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8。升级MySQL服务端版本时,驱动也要换成对应的mysql-connector-j坐标,否则“服务端8.4 + 驱动8.0.11”这类组合会在建连握手时报错。用MySQL命令行验证时,mysql -uroot -p进库后先看SELECT VERSION();,别让版本问题干扰后续判断。
5.3 并发预算扣减超扣:条件更新传参这个细节
现象:压测时同一账户并发100个请求,跑完发现used_amount超过了daily_budget。
原因:应用层先SELECT余额判断够不够,再UPDATE扣减。两个操作之间,其他事务已经改了used_amount,后一个事务基于旧值做判断,导致超扣。这个问题在单实例里不明显,多实例部署后必现。
解决:把余额判断写进UPDATE的WHERE条件里,像第三章那条SQL一样。或者用SELECT ... FOR UPDATE锁定行再做计算,但锁持有时间更长,高并发下容易引起锁等待上升。条件更新影响行数为0时,服务端要返回预算不足并中断链路。还有一个隐藏坑:如果用了MyBatis,#{cost}参数类型是BigDecimal,SQL里比较的是DECIMAL,没问题;但如果把cost算成double再传入,精度丢失会在临界值出现多扣一分钱。
5.4 报表数据对不上:限流降级流量混入统计
现象:报表系统显示点击数和财务账单不一致,多了一些没有消耗的曝光记录。
原因:投放决策接口被Sentinel限流后,有些实现会把bypass结果也写入曝光明细表,用来记录“请求到达但未投放”。如果不加标识,报表侧SELECT COUNT(*)时把这些降级流量也统计进去了。
解决:在曝光明细表里加request_status字段,0表示正常投放,1表示限流降级,2表示预算耗尽。报表统计的WHERE条件必须带request_status = 0。源码里如果已经区分了ad_decision_result与ad_click_log两张表,那么限流结果只写前者,点击明细只写真实投放数据,这个问题的概率会小很多。
5.5 改了Nacos配置不生效:@RefreshScope与配置归属
现象:在Nacos控制台改了投放时段开关,服务没有任何反应,两分钟后再看还是旧配置。
原因:服务没有监听配置变化。SpringCloudAlibaba的配置中心生效有两个前提:一是从Nacos拉取的配置确实在控制台存在,二是需要动态刷新的Bean标了@RefreshScope。如果只配置了bootstrap.yml,但控制台没有对应dataId,服务启动时静默失败,后续怎么改都无效。
解决:先检查Nacos“配置管理”里有没有ad-delivery-service.yml这个dataId;再确认本地bootstrap.yml里的spring.application.name与dataId前缀完全一致;最后在要刷新的Bean上确认注解。还有一个容易被忽略的点:本地同时存在application.yml和Nacos配置时,Nacos里的配置优先级更高,但spring.config.import的写法会影响是否覆盖本地文件,源码里两种写法都有,排查时先看日志打印的配置来源再动手改。
6. 进阶验证:让这套广告投放系统经得住压测和展示
跑通源码只是起点,真正要把它变成简历项目或内部系统,必须做三件事:压测投放决策接口、验证Sentinel限流效果、排查MySQL慢查询。
压测工具用JMeter就够了,线程组分别设50、100、200并发,每个梯度跑5分钟,观察ad-plan服务的P99延迟和MySQL连接池占用。压测时会发现MySQL连接池满的报错,因为每个线程在事务里持有连接时间过长。常见的解法是调整maximum-pool-size与minimum-idle,但这个参数不能盲调,先压测后看监控再改。
验证Sentinel限流是否生效,最直接的是硬编码规则:
FlowRule rule = new FlowRule(); rule.setResource("ad_decision"); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); rule.setCount(200); FlowRuleManager.loadRules(Collections.singletonList(rule));setCount(200)表示该资源每秒最多通过200个QPS,超出的请求会触发blockHandler。压测时观察返回体的traceId是否能对应到降级标识,确认规则确实在起作用。如果按这个方式改完没效果,检查资源名是否与@SentinelResource的value完全一致,大小写、拼写都不能差。
最后把MySQL慢查询日志开起来:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 0.5;SET GLOBAL对当前实例立即生效,但重启MySQL后会失效,要持久化得写进my.cnf。开慢日志后重点看两类语句:一类是报表服务里的GROUP BY查询,对应第三章的覆盖索引是否建上;另一类是预算扣减的UPDATE,如果它的affected rows频繁为0,说明预算耗尽,这是业务逻辑正常表现,但如果同一账户连续出现大量预算不足,要在投放策略上做限流,而不是靠MySQL硬扛。
我自己的习惯是压测跑完不删JMeter脚本,也不会删慢日志的开启命令,留着这一套就是给后续接手的同事交底。源码能跑通只说明依赖对了,压测不翻车、报表对得上、配置改得动,这套广告投放系统才真正变成你的工程交付物。希望帮到你。
本文还有配套的精品资源,点击获取