2023年4月

集群化工具选择性很多,这里选 Consul 工具;官网:
https://www.consul.io

本篇计划用 Docker 辅助部署,所以需要了解点 Docker 知识;官网:
https://www.docker.com

一、Consul 概括

Consul 是由N多个节点
(台机/虚机/容器)
组成,每个节点中都有 Agent 运行着,各节点间用RPC通信,所有节点内相同的 Datacenter 名称为一个数据中心,节点又分三种角色 Client/Server/Leader:

  • Agent:Consul 各成员节点的运行载体
  • Client:不是必须存在的角色,数量也无上限,少量的资源开销,建议更多的 Client 角色存在。
  • Server:必须的Server角色,每个Server下可多个Client,可以代替Client,对接收到的信息持久化;资源开销大,官方建议3/5台。
  • Leader:一个数据中心内的 Server 选举产生一个 Leader 角色,将信息下发广播给所有 Server

默认端口

  • 8500:Consul 对外提供注册查询UI等的专用端口
  • 830x:Consul 内各节点间TCP/RPC等通信的专用端口
  • 8600:Consul DNS 使用
  • 21xxx:Consul 自动分配代理使用

整体架构示意图

图解
:任意的 应用服务 Join 到任意的 Consul Node;任意的 Client Join 到任意的 Server;Node之间数据共享。

Consul 中的服务 与 注册的应用服务

Consul Node Server:是组成 Consul 整体运行的不可缺少的一种节点角色,注册于 Catalog 中,不如后续叫<
节点服务
>
Agent Register Service:是被 Consul 管理、发现、健康检测的目标业务应用,注册于 Agent 中,不如后续叫<
应用服务
>

作者:[
Sol·wang
] - 博客园,原文出处:
https://www.cnblogs.com/Sol-wang/p/17296278.html

二、Consul 功能

服务注册

所有的应用服务,都向 Consul 报告自己的存在及具体的信息;

新应用服务的加入,通过Client/Server上报给上级,直至Leader;Leader再向所有Server广播新服务的存在及具体信息,Consul 中所有节点共享新加入服务的信息;其中包括应用服务本身的连接及健康检测信息。

任何注销的应用服务,Consul 也会同步到各节点,关联的健康检测一并注销。

作者:[
Sol·wang
] - 博客园,原文出处:
https://www.cnblogs.com/Sol-wang

健康检测

Consul向各应用服务发起的连接过程,为了提供所有健康可用的应用服务,按提供的检测方式、检测地址、检测频率等,发起通信检测,识别服务状态,踢除异常及不可用的实例,保留健康可用的实例,并把结果上报给 Consul-Server/Leader。

检测方式分为:script / http / tcp / udp / ttl / rpc 等

比如:
脚本在各服务上的运行反馈

比如:
各服务提供HTTP请求的API

比如:
各服务提供TCP连接的端口

服务发现

在集群内外,任何想要连接集群内应用服务的信息,先通过 Consul-Server 拉取到健康可用的应用服务信息,才能连接到指定的应用服务;

新应用服务不断的注册/宕机/注销等,每个时间段所提供的各应用服务信息都可能是变化的;

这种 Consul-Server 提供的健康服务地址列表的过程,给出了所有可用的应用服务信息,就叫做服务发现。

比如:
Nginx需要知道[订单服务]的访问IP端口信息,才好转发请求

比如:
[订单服务]需要请求[产品服务]API,Consul提供了所有健康的[产品服务]访问IP端口信息,[订单服务]才能请求到[产品服务]API

按[订单]服务查询出健康可用的应用服务列表,屏蔽了异常状况的应用服务,达到了
故障转移
的效果。

K/V存储

动态的、可维护的、持久化的、键值对的存储方式;比较独立的一项Consul功能,我们可以把需要动态的内容放入KV中存储,它就像库一样,随时可变更查询。

key 唯一键;value 对应值;flags 64位整数可选值

GET 查询/列表

# 命令行 查询全部
consul kv get -recurse
# 命令行 查询单个[列表]
consul kv get [-detailed] {key}
# API 查询全部
curl http://{host}:8500/v1/kv/?recurse
# API 查询单个
curl http://{host}:8500/v1/kv/{key}

PUT 新增/修改

# 命令行 新增/修改
consul kv put [-flags=13] {key} {value}
# API 新增/修改
curl -X PUT -d '{value}' http://{host}:8500/v1/kv/{key}[?flag=13]

DELETE 删除/全部

# 命令行 删除一个
consul kv delete {key}
# 命令行 删除列表(全部)
consul kv delete -recurse [key/prefix]
# API 删除列表(全部)
curl -X DELETE http://{host}:8500/v1/kv/{key/prefix}[?recurse]

更多相关API参考:
https://developer.hashicorp.com/consul/api-docs/kv

Datacenter

数据中心算是一个概念吧。。。

以上几点内容大致体现了 Consul 的运作方式,综合起来也就是一个范围集群的说法,其中会按 Datacenter 名称的不同,区分为多个数据中心。比如在不同的地域提供不同的数据中心,或者相同的数据中心互通,以做候选备用等。

三、集群相关

负载均衡

在集群中,每种应用服务都可能不止一个运行实例,订单服务A调用产品服务B,通过ConsulAPI给出的产品服务B可用地址会是多个,同样都是产品服务,有的资源已用90%,有的资源才用10%,为了避免这种资源利用不均匀,如何做到负载均衡呢?

常用方式:随机、轮询、最小连接、权重 等。

  • 随机方式
    :实现起来比较简单,在拉取到的应用服务数据列表中,随意挑一个使用就好
  • 轮询方式
    :需要有个全局变量,记录当前已用到哪个地址了,下标+1的方式取列表中下个健康的地址
  • 最小连接
    :记录每个应用服务实例当前已产生多少连接,每次使用最小已连接的实例做为本次的连接
  • 权重方式
    :配置应用服务实例在整体服务中所占的使用比例上限,每次连接后计算更新已连接的占比

当然,Consul未提供此功能,或用第三方或自己编写实现;

比如:
写一个负载均衡的类库,每个服务已连接次数记录在 Consul 的 KV 中,调用方给出要调用的服务组名,使用类库得出本次要请求的具体服务地址等。

熔断降级

Consul 并没有提供这样的功能,作为集群中不可忽视的点,这里只有粗略叙述,以作了解。

熔断:存在于请求方与应用服务之间,当应用服务异常次数达到指定值,下次请求就在熔断处直接返回,不用再连接到异常应用服务上。

降级:也就是备用方案的启用;比如:DB异常时,用缓存的数据;缓存异常时,或保留请求信息做延迟处理;或默认数据的返回等。

Snapshot

对于集群的灾难与备份,上述有提到多数据中心同步可达到备份的效果,Consul 的快照方式也是一个可选项:

# 命令行 生成快照
consul snapshot save {backup-name}.snap
# API 生成快照
curl http://{host}:8500/v1/snapshot?dc={dc-name} --output {backup-name}.snap
# 命令行 恢复快照
consul snapshot restore {backup-name}.snap
# API 恢复快照
curl -X PUT --data-binary @{backup-name}.snap http://{host}:8500/v1/snapshot
# 命令行 快照详细
consul snapshot inspect {backup-name}.snap

定期生成快照
consul snapshot agent
仅企业版可用

四、Consul 部署

以下用 docker 部署,docker 拉取镜像:
docker pull consul

不管是 Client / Server / Leader 哪种角色,都是 Agent 运行起来的 Node;以下通过两种方式来创建维护 Consul Node:

命令行方式管理节点

Consul 中的每个节点都是用 Agent 运行的,创建节点的命令格式如下:

docker run -d --name={容器名称} -p 8500:8500 {image} agent -server -ui -node={节点名称} -bootstrap-expect=3

下表列出了常用各参数的作用说明:

agent 必须;Consul 的应用,于每个节点中
-server 必须,服务角色;无:被视为Client角色
-node 必须;本节点名称
-bootstrap-expect 必须;定义Server角色的数量,必须够数,才能成为一个集群,否则集群不会运行
-datacenter 数据中心名称(群名称),默认 dc1
-join 加入的节点IP地址(Client/Server)
-retry-join 尝试重新加入时的节点IP(Client/Server)
-data-dir 指定运行时的数据存放目录
-config-file 使用指定的配置文件启动运行(文件内容与此表参数项作用相似)
-ui 带管理Web页面;访问服务器IP http://{ip}:8500 进入页面管理方式
-client 连接限制,开放连接的客户端;浏览器连接打开UI、Client连接Server等

下面来部署一个 Consul Datacenter,计划有3个 Server 节点,3个 Client 节点。

创建3个 Server 节点

节点名称分别定为:ser-a / ser-b / ser-c;
由于 Datacenter 的默认值都是 dc1,所以就形成了一个名为 dc1 的数据中心。

# 第一个 Server Node,所以 Consul 会默认为 Leader 角色
docker run -d --name=cons-ser-a -p 8501:8500 consul agent -server -ui -node=ser-a -bootstrap-expect=3 -client=0.0.0.0
# 以下的 Server Node 都 Join 到 Leader Node 上
docker run -d --name=cons-ser-b -p 8502:8500 consul agent -server -node=ser-b -ui -client=0.0.0.0 -retry-join=172.17.0.2
docker run -d --name=cons-ser-c -p 8503:8500 consul agent -server -node=ser-c -ui -client=0.0.0.0 -retry-join=172.17.0.2

起初创建 Leader Node 时
bootstrap-expect=3
,现在已经运行了3个 Server Node;
所以 Consul 已经启动完成并运转;可以打开UI界面:
http://{宿主机IP}:8501/

再创建3个 Client 节点
,并加入到不同的 Server Node

docker run -d --name=cons-cli-a -p 8511:8500 consul agent -node=cli-a -client=0.0.0.0 -retry-join=172.17.0.2
docker run -d --name=cons-cli-b -p 8512:8500 consul agent -node=cli-b -client=0.0.0.0 -retry-join=172.17.0.3
docker run -d --name=cons-cli-c -p 8513:8500 consul agent -node=cli-c -client=0.0.0.0 -retry-join=172.17.0.4

以上节点的创建过程,是用宿主机的不同端口映射到各自容器节点的8500端口,所以用支持UI的对应宿主机端口都可以打开UI界面

计划的所有 Consul 节点创建完成,Consul 就是用这些节点来管理应用服务集群的,应用服务等后续再加入;以下先阐述节点的维护

查看 Datacenter 成员

# 命令行 列出所属 Datacenter 中的全部成员 [详细]
docker exec -t {容器名称} consul members [-detailed]
# 命令行 列出所属 Datacenter 中的 Server 角色成员
docker exec -t {容器名称} consul operator raft list-peers

Server 加入到 Leader 下

# 如果要加 -datacenter 的话,必须与 join 参数目标的dc名称一致
docker run -d --name={容器名称} {image} agent -server -node={node-name} -join={leader-ip}

Client 加入到 Server 下

# 创建时加入
docker run -d --name={容器名称} {image} agent -datacenter={有要一致} -node={name} -join {server-ip}
# 创建后加入
docker exec -t {容器名称} consul join {server-ip}

下线指定节点

# 命令行 移除所处节点
consul leave
# 命令行 强制移除指定节点 [清楚未运行的节点]
consul force-leave {node-name} [-prune]

不止命令行方式,Consul 也提供了 API 方式来管理节点。

API 方式管理节点

# API 列出所有成员
curl http://{host}:8500/v1/catalog/nodes
# API 加入新节点
# 参数文件 json 指明了节点名称/地址/端口等必要项
curl -X PUT -d @cli-d.json http://{host}:8500/v1/catalog/register
# API 注销节点
curl -X PUT -d '{"Datacenter":"dc1","Node":"cli-d"}' http://{host}:8500/v1//catalog/deregister

有没有麻烦了点。。。 这种方式应该用的不多吧。。。

Consul 6个节点已部署完成,接下来该在 Consul Node 上部署微服务了。

五、应用服务部署

Consul Node 部署完成以后呢,剩下的就是告知 Consul 有哪些应用服务需要你管理,这告知 Consul 的方式有以下几种:

命令行方式注册服务

假设应用服务已经运行起来,然后按 Consul 定义的应用服务配置格式,编写配置文件,放到 Consul 任意节点的配置目录下,文件名称自定义,每次 Consul 启动的时候,都会读取配置目录下的所有文件。

配置内容也就是告诉 Consul 我是谁、我在哪、怎么与我联系、我的检测方式等;

1、配置示例格式如下:

{
  "service": [
    {
      // 服务身份定义
      "id": "AppService-Redis-aaaaa-bbbb-cccc-dddd-eeeee",
      "name": "AppService-Redis",
      "port": 80,
      // 健康检测定义
      "check": {
        "id": "AppService-Redis-Check-TCP",
        "name": "Redis Check TCP on port 80",
        "tcp": "172.17.0.10:80",
        "interval": "10s",
        "timeout": "3s"
      }
    }
  ]
}

2、重载此节点配置:
docker exec -t cons-ser-a consul reload

至此完成应用服务注册;如下效果图:

以上配置文件:有 Service 项 就是 服务注册,有 Check 项 就是 健康检测注册。

那。。。注销健康检测呢?注销服务呢?

删除 Check 项 就是注销健康检测;删除文件 就是 注销服务;记得重新加载配置
consul reload

API 方式注册服务

几乎命令行能做的事情,Consul 提供的 API 方式也可以做到。

假设应用服务已经运行起来了,同样是编写配置文件,以传参的方式请求注册的 API,完成 服务注册、健康注册、服务注销、健康注销等。

以下案例准备了服务注册所需的参数文件:AppService-kafka.json

{
  "service": [
    {
      // 服务身份定义
      "id": "AppService-Kafka-xxxxx-yyyy-zzzz-wwww-vvvvv",
      "name": "AppService-Kafka",
      "port": 80
    }
  ]
}

带文件参数请求注册服务的API接口:

curl -X PUT -d @AppService-kafka.json http://{host}:8500/v1/agent/service/register

当然,也可以单独请求注册健康检测的API接口:

curl -X PUT -d @AppService-health.json http://{host}:8500/v1/agent/check/register

同样的,API注销接口:

curl -X PUT http://{host}:8500/v1/agent/service/deregister/{app-service-id}

curl -X PUT http://{host}:8500/v1/agent/check/deregister/{app-check-id}

更所相关API接口参考官网:
https://developer.hashicorp.com/consul/api-docs

引用类库方式注册服务

1、
创建 .NET 项目

,并启用 Docker 方式,假设叫[订单服务],Nuget 安装 Consul

[订单服务]中必须要完成开发的事情:服务注册动作,健康检测定义,服务注销动作。

其实就是把 Consul 类库相关的参数赋值,由 Consul 类库自动完成注册/检测/注销。

2、
appsettings 相关配置

"Consul": {
    "Service-Name": "AppService-Order",
    "Service-Port": 80,
    "Service-Health": "/Health",
    // 应用服务 Join to Node Address
    // 未来 Join 的 Node 不固定;所以 创建容器时 再传参
    "Register-Address": null
}

3、扩展 IApplicationBuilder
实现 注册/检测/注销
并启用

以下扩展方法创建后,并在管道中启用:
app.UseConsul(app.Configuration, app.Lifetime);

public static IApplicationBuilder UseConsul(this IApplicationBuilder app, IConfiguration conf, IHostApplicationLifetime lifetime)
{
    // 取本机IP(告诉 Consul 健康检测的地址)
    string? _local_ip = NetworkInterface.GetAllNetworkInterfaces()
        .Select(p => p.GetIPProperties())
        .SelectMany(p => p.UnicastAddresses)
        .FirstOrDefault(p =>
            p.Address.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(p.Address)
        )?.Address.ToString();


    // 指明注册到的 Consul Node
    var client = new ConsulClient(options =>
    {
        options.Address = new Uri(conf["Consul:Register-Address"]);
    });
    
    
    // 应用服务的 服务信息 / 健康检测
    var registration = new AgentServiceRegistration
    {
        Name = conf["Consul:Service-Name"],
        ID = $"Order-{Guid.NewGuid().ToString()}",
        Address = _local_ip,
        Port = Convert.ToInt32(conf["Consul:Service-Port"]),
        Check = new AgentServiceCheck
        {
            Timeout = TimeSpan.FromSeconds(5),
            Interval = TimeSpan.FromSeconds(10),
            DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(5),
            HTTP = $"http://{_local_ip}:{conf["Consul:Service-Port"]}{conf["Consul:Service-Health"]}"
        }
    };
    
    
    // 应用服务启动时 - 注册
    lifetime.ApplicationStarted.Register(() =>
    {
        client.Agent.ServiceRegister(registration).Wait();
    });
    // 应用服务停止时 - 注销
    lifetime.ApplicationStopping.Register(() =>
    {
        client.Agent.ServiceDeregister(registration.ID).Wait();
    });

    return app;
}

4、
编写健康检测 API

这里健康检测选用 HTTP API 方式;为此,需要编写 WebApi。

# 服务中追加健康检测接口,供 Consul 调用
# 与 appsettings 配置保持一致的控制器
# 比较简单,达到通信正常,就被视为服务运行正常
public class HealthController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        return Ok();
    }
}

5、
生成 Docker 镜像并运行

每个应用服务都会运行多个实例,把已开发的[订单服务]用Docker运行多个实例,模仿集群环境:

# 项目根目录 生成 docker 镜像
docker build -t order.serv.cons:dev -f ./Order-Web/Dockerfile .
# docker 运行 [订单服务],这里运行2个吧(多实例)
# 还记得 appsettings 中的注册节点地址么...这里传参指明注册的节点
docker run -d --name=order-serv-cons.a -p 5001:80 order.serv.cons:dev --Consul:Register-Address='http://172.17.0.6:8500'
docker run -d --name=order-serv-cons.b -p 5002:80 order.serv.cons:dev --Consul:Register-Address='http://172.17.0.7:8500'

测试验证效果

运行2个 [订单服务] 后的 Consul-UI 图示:

新的服务 AppService-Order 下有两个运行健康的实例,注册与检测都是类库帮助实现的,并显示出IP及注册节点。

相同的服务,有多个运行实例;不同的服务,组成完整的微服务集群;被 Consul 的6个 Node 时时管理着。

测试服务发现

1、Consul API 方式 指定服务的健康实例查询:
http://{host}:8501/v1/health/service/{service-name}?passing

2、Consul 类库方式 拉取指定服务的健康实例地址:

// 指明一个节点地址
var _consul_node = new ConsulClient(options =>{ options.Address = new Uri(_conf["Consul:Node-Address"]); });
// 向 Consul 拉取 指定服务 健康实例的列表
var _order_result = await _consul_node.Health.Service("AppService-Order", null, true, _query_options);
var _order_instance_list = _order_result.Response.Select(i => $"http://{i.Service.Address}:{i.Service.Port}");

服务异常测试

docker 停止一个容器后,只剩一个运行实例:

当然,docker 容器再启动后,实例还会再回来。

服务间的相互调用,X服务 -> 订单服务,通过以上方式得出多个可用的实例地址,假如用轮询方式实现了
负载均衡

后续也可以把各应用服务的可用列表,时时的提供给网关(如Nginx),实现网关中的
负载均衡

Consul:Service Mesh & Gateways

consul connect proxy:集群代理,通过调用代理者的端口,实现与被代理者的连接(像是两台机的端口映射似的,被代理者端口不被暴露)

Consul Service Mesh:在多个集群和环境间建立连接,创建全球化的跨平台服务网络;下有多个Mesh Gateway,每个Mesh Gateway 下是 Datacenter。

待续...

Midjourney 是一款非常特殊的 AI 绘画聊天机器人,它并不是软件,也不用安装,而是
直接搭载在 Discord 平台之上
,所有的功能都是通过调用 Discord 的聊天机器人程序实现的。要想使用 Midjourney,只能进入他们的 Discord 服务器,并选择其中一个频道然后调用指令,输入 Prompt 提示词即可。

问题就在这里。

Midjourney 的提示词并不完全是自然语言,需要很多的技巧描述主题和设计风格以及画面设定等等。虽然官方也提供了文档,但是学习也是需要花成本的,关键词的数量非常多,不同关键词的结合、顺序、语境都会造成不同的结果,但目前官方没有给出一套标准化的公式,我们也无法像使用常规工具那样非常精准的输出。有没有办法无需学习就能直接上手呢?
直接输入我的原始描述就能生成相应的画作
,岂不美哉?

聪明的你应该想到了 ChatGPT,通过 ChatGPT 的自然语言理解和 Midjourney 的绘画能力,可以将
文字描述
转化为
绘画指令
,让 Midjourney 成功画出各种美妙的画作。

这里的难点在于如何利用 ChatGPT 将用户的中文输入准确地转化为 Midjourney 的绘画指令。不用担心,我这里提供了一个现成的 ChatGPT Prompt 提示词:

从现在开始,你是一名中英翻译,你会根据我输入的中文内容,翻译成对应英文。请注意,你翻译后的内容主要服务于一个绘画AI,它只能理解具象的描述而非抽象的概念,同时根据你对绘画AI的理解,比如它可能的训练模型、自然语言处理方式等方面,进行翻译优化。由于我的描述可能会很散乱,不连贯,你需要综合考虑这些问题,然后对翻译后的英文内容再次优化或重组,从而使绘画AI更能清楚我在说什么。请严格按照此条规则进行翻译,也只输出翻译后的英文内容。
例如,我输入:一只想家的小狗。
你不能输出:
/imagine prompt:
A homesick little dog.
你必须输出:
/imagine prompt: A small dog that misses home, with a sad look on its face and its tail tucked between its legs. It might be standing in front of a closed door or a gate, gazing longingly into the distance, as if hoping to catch a glimpse of its beloved home.
如果你明白了,请回复"我准备好了",当我输入中文内容后,请以"/imagine prompt:"作为开头,翻译我需要的英文内容。

高贵的 ChatGPT Plus 用户已经可以体验到目前 OpenAI 最强大的 GPT4 模型,建议
人傻钱多的憨憨
有钱的成功人士直接开会员体验 GPT4。

如果你不想花钱又想体验 GPT4,可以加入我们 Sealos 官方的 Discord 群组免费体验:
https://discord.gg/eDH3wscx

先来看看效果:

说明 ChatGPT 理解了我的需求,并给出了预期的回答。下面把提示词贴到 Midjourney 中:

完美!

我觉得第三张图不错,直接点击
U3
,便会将第三张图的高清大图发给我。

怎么样,效果还不错吧?

再来看看更强的,拿
《阿房宫》
试一下:

最终画出来的图效果如下:

还真就五步一楼,十步一阁啊?

再来试试陶渊明的
《桃花源记》

太强了!假以时日,以后所有的故事情节都可以用 AI 来做插画了。


最后,如果你也想将 ChatGPT 接入 Discord,可以参考下面的步骤。

首先你需要打开这个页面
https://discord.com/developers/applications
创建一个 Discord Application,然后在这个 Application 中创建一个 Discord 机器人,在 Bot 的设置页面中找到 token 并复制下来。


MESSAGE CONTENT INTENT
打开:

通过
OAuth2 URL Generator
将机器人邀请到你的服务器中:

下面需要用到一个可以将 ChatGPT 对接到 Discord 机器人的项目:
https://github.com/Zero6992/chatGPT-discord-bot

该项目虽然提供了 Dockerfile,但是没有提供构建好的镜像,我提交了自动构建镜像的 PR 也还没有合并。不过问题不大,我自己构建了镜像,大家可以先用我的:
ghcr.io/yangchuansheng/chatgpt-discord-bot:latest

要想通过容器来运行该项目,首先需要找个能访问 ChatGPT 的环境。接下来需要用到一个非常神奇的云操作系统:
Sealos

虽然它是基于 Kubernetes 作为内核,但是它跟其他所有基于 Kubernetes 的平台都不一样,你和它进行交互的唯一方式就是云桌面:

云桌面上有各种 App,与个人电脑几乎无异。

像使用个人电脑一样在 Kubernetes 上一键安装任意高可用分布式应用程序,几乎不需要任何专业的交付和运维成本。当然,你也不需要知道 Kubernetes 是个啥,也不用具备与 Kubernetes 相关的任何知识,就是这么神奇。

直接在云桌面中打开 Deploy Manager,然后点击「新建应用」:

填入应用名称、镜像名,设置一下需要用到多少 CPU 和内存,以及实例数量。

在高级配置中新增一些环境变量:

完整的环境变量配置可以通过 yaml 查看:

  • DISCORD_BOT_TOKEN
    就是上文提到的 Discord 机器人的 token。
  • DISCORD_CHANNEL_ID

    REPLYING_ALL_DISCORD_CHANNEL_ID
    填入的都是你的 Discord 频道 ID。

我这里演示的是通过 ACCESS token 来登录 ChatGPT,
PUID

ACCESS_TOKEN
的获取步骤如下:

  1. Chrome 登录 ChatGPT 网页,打开浏览器调试,依次进入 Application --> Cookies;

  2. 复制
    _puid
    的值,将其作为环境变量
    PUID
    的值填入 Deploy Manager;

  3. Chrome 打开这个 URL:
    https://chat.openai.com/api/auth/session

  4. 复制
    accessToken
    的值,将其作为环境变量
    ACCESS_TOKEN
    的值填入 Deploy Manager。

最终点击「部署应用」,一个崭新的容器就运行成功了:

点击「详情」进入容器详情页面,再点击「日志」就可以看到容器的日志了。

来测试一下吧:

溜了溜了~

大家好,我是
王有志
,欢迎和我聊技术,聊漂泊在外的生活。快来加入我们的Java提桶跑路群:
共同富裕的Java人

最近公司在搞新项目,由于是实验性质,且不会直接面对客户的项目,这次的技术选型非常激进,如,直接使用了Java 17。

作为公司里练习两年半的个人练习生,我自然也是深度的参与到了技术选型的工作中。不知道大家在技术选型中有没有关注过技术组件给出的基准测试?比如说,
HikariCP的基准测试

又或者是
Caffeine的基准测试

如果你仔细阅读过它们的基准测试报告,你会发现一项很有意思的技术:Java Microbenchmark Harness,简称
JMH

Tips
:有些技术只需要学会如何使用即可,没有必要非得“卷”源码;有些“小众”技术你没有听过,也不必慌,没有人是什么都会的。

认识JMH

接触JMH之前,我通常用
System.currentTimeMillis()
来计算方法的执行时间:

long start = System.currentTimeMillis();
......
long duration = System.currentTimeMillis() - start;

大部分时候这么做都很灵,但某些场景下JVM会进行JIT编译和内联优化,导致代码在优化前后的执行效率差别非常大,此时这个“土”方法就不灵了。那么该如何准确的计算方法的执行时间呢?

Java团队为开发者提供了JMH基准测试套件:

JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks written in Java and other languages targeting the JVM.

JMH是用于构建,运行和分析Java和其它基于JVM的语言编写的程序的基准测试套件
。JMH提供了预热的能力,通过预热让JVM知道哪些是热点代码,除此之外,JMH还提供了吞吐量的测试指标。相较于“土”方法,JMH可以支持
更多种的测试场景
,而且基于JMH得出的测试结果也会
更全面,更准确

使用JMH

项目中引入JMH的依赖:

<dependency>
  <groupId>org.openjdk.jmh</groupId>
  <artifactId>jmh-core</artifactId>
  <version>1.36</version>
  <scope>test</scope>
</dependency>
<dependency>  
  <groupId>org.openjdk.jmh</groupId>  
  <artifactId>jmh-generator-annprocess</artifactId>  
  <version>1.36</version>  
</dependency>

引入依赖后就可以编写一个简单的基准测试了,这里使用简化后的JMH
官方示例

package org.openjdk.jmh.samples;

import org.openjdk.jmh.annotations.Benchmark;  
import org.openjdk.jmh.annotations.BenchmarkMode;  
import org.openjdk.jmh.annotations.Mode;  
import org.openjdk.jmh.annotations.OutputTimeUnit;  
import org.openjdk.jmh.runner.Runner;  
import org.openjdk.jmh.runner.RunnerException;  
import org.openjdk.jmh.runner.options.Options;  
import org.openjdk.jmh.runner.options.OptionsBuilder;  
  
import java.util.concurrent.TimeUnit;

public class JMHSample_02_BenchmarkModes {

  @Benchmark
  @BenchmarkMode(Mode.AverageTime)
  @OutputTimeUnit(TimeUnit.MILLISECONDS)
  public void measureAvgTime() throws InterruptedException {
    TimeUnit.MILLISECONDS.sleep(100);
  }
  
  public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
                .include(JMHSample_02_BenchmarkModes.class.getSimpleName())
                .forks(1)
                .build();
    new Runner(opt).run();
  }
}

执行这个示例,会输出如下结果:

以空行为分割的话,JMH的输出可以分为3个部分:

  • 基础信息
    ,包括环境信息和基准测试配置;
  • 测试信息
    ,每次预热(Warmup)和正式执行(Iteration)的信息;
  • 结果信息
    ,基准测试的结果。

Tips

  • IDEA中不能使用DeBug模式运行,否则会报错
  • 注意依赖中的scope标签为test,在src\main\java路径下是无法访问到JMH的。

启动测试

从示例中不难发现,在IDEA中执行测试需要先构建
Options
,并通过
Runner
去执行。我们来构建一个最简单的
Options

Options opt = new OptionsBuilder().build();

new Runner(opt).run();

这样的
Options
会执行散落在程序各处的基准测试方法(使用
Benchmark
注解的方法)。如果不需要执行所有的基准测试方法,通常在构建
Options
时会指定测试的范围:

Options opt = new OptionsBuilder()
                  .include(JMHSample_02_BenchmarkModes.class.getSimpleName())
                  .build();

这时基准测试仅限于
Test
类中的基准测试方法。除此之外,你可能还会嫌弃控制台输出样式丑陋,或者要提交的基准测试报告中需要用图示来直观的表达,这个时候可以控制输出结果的格式并指定结果输出文件:

Options opt = new OptionsBuilder()
                  .include(JMHSample_02_BenchmarkModes.class.getSimpleName())
                  .result("result.json")
                  .resultFormat(ResultFormatType.JSON)
.build();

再结合以下网站,可以很轻松的构建出测试结果图示:

例如,我通过JMH Visual Chart构建出的测试结果:

实际上,
OptionsBuilder
提供的功能远不止如此,不过其中大部分功能都可以通过下文中提到注解进行配置,在此就不进行多余的说明了。

常用注解

JMH可以通过注解非常简单的完成基准测试的配置,接下来对其中常用的15个注解进行详细说明。

注解:Benchmark

注解
Benchmark
的声明:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Benchmark {
}

Benchmark
用于方法上且该方法必须使用
public
修饰,
表明该方法为基准测试方法

注解:BenchmarkMode

注解
BenchmarkMode
的声明:

@Inherited  
@Target({ElementType.METHOD, ElementType.TYPE})  
@Retention(RetentionPolicy.RUNTIME)  
public @interface BenchmarkMode {
  Mode[] value();  
}

BenchmarkMode
用于方法或类上,
表明测试指标
。枚举类Mode提供了4种测试指标:

  • Mode.Throughput

    吞吐量
    ,单位时间内执行的次数;
  • Mode.AverageTime

    平均时间
    ,执行方法的平均耗时;
  • Mode.SampleTime

    操作时间采样
    ,并输出结果分布;
  • Mode.SingleShotTime

    单次操作时间
    ,通常在不进行预热时测试冷启动的时间。

我们来看下
Mode.SampleTime
的输出结果:

除单独使用以上测试指标外,还可以指定
Mode.All
进行全部指标的基准测试。

注解:OutputTimeUnit

注解
OutputTimeUnit
的声明:

@Inherited
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface OutputTimeUnit { 
  TimeUnit value();
}

OutputTimeUnit
用于方法或类上,
表明输出结果的时间单位
。好了,示例中的注解我们已经了解完毕,接下来我们看其它较为关键的注解。

注解:Timeout

注解
Timeout
的声明:

@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface Timeout {

  int time();
  
  TimeUnit timeUnit() default TimeUnit.SECONDS;
}

Timeout
用于方法或类上,指定了基准测试方法的超时时间**。

注解:Warmup

注解
Warmup
的声明:

@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface Warmup {
  int BLANK_ITERATIONS = -1;
  int BLANK_TIME = -1;
  int BLANK_BATCHSIZE = -1;

  int iterations() default BLANK_ITERATIONS;

  int time() default BLANK_TIME;

  TimeUnit timeUnit() default TimeUnit.SECONDS;

  int batchSize() default BLANK_BATCHSIZE;
}

Warmup
用于方法或类上,
用于做预热配置
。提供了4个参数:

  • iterations
    ,预热迭代的次数;
  • time
    ,每个预热迭代的时间;
  • timeUnit
    ,时间单位;
  • batchSize
    ,每个操作调用的次数。

预热的执行结果并不会被统计到测试结果中
,因为JIT机制的存在某些方法被反复调用后,JVM会将其编译为机器码,使其执行效率大大提高。

注解:Measurement

注解
Measurement
的声明:

@Inherited
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Measurement {
  int BLANK_ITERATIONS = -1;
  int BLANK_TIME = -1;
  int BLANK_BATCHSIZE = -1;

  int iterations() default BLANK_ITERATIONS;
  
  int time() default BLANK_TIME;
  
  TimeUnit timeUnit() default TimeUnit.SECONDS;
  
  int batchSize() default BLANK_BATCHSIZE;
}

Measurement

Warmup
的使用方法完全一致,参数含义也完全相同,区别在于
Measurement
属于
正式测试的配置,结果会被统计

注解:Group

注解
Group
的声明:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Group {
  String value() default "group";
}

Group
用于方法上,
为测试方法分组

注解:State

注解
State
的声明:

@Inherited
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface State {
  Scope value();
}

State
用于类上,
表明了类中变量的作用范围
。枚举类
Scope
提供了3种作用域:

  • Scope.Benchmark
    ,每个测试方法中使用一个变量;
  • Scope.Group
    ,每个分组中使用同一个变量;
  • Scope.Thread
    ,每个线程中使用同一个变量。

忘记了是在哪看到有人说
Scope.Benchmark
的作用域是所有的基准测试方法,这个是错误的,
Scope.Benchmark
会为每个基准测试方法生成一个对象,例如:

@State(Scope.Benchmark)
public static class ThreadState {
}

@Benchmark
@BenchmarkMode(Mode.SingleShotTime)
public void test1(State state) {
  System.out.println("test1执行" + VM.current().addressOf(state));
}

@Benchmark
@BenchmarkMode(Mode.SingleShotTime)
public void test2(State state) {
  System.out.println("test2执行" + VM.current().addressOf(state));
}

这个例子中,
test1

test2
使用的是不同的State对象。

Tips

VM.current().addressOf()

jol-core
中提供的功能。

注解:Setup

注解
Setup
的声明:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Setup {
  Level value() default Level.Trial;
}

Setup
用于方法上,
基准测试前的初始化操作
。枚举类
Level
提供了3个级别:

  • Level.Trial
    ,所有基准测试执行时;
  • Level.Iteration
    ,每次迭代时;
  • Level.Invocation
    ,每次方法调用时。

Tips
:一次迭代中,可能会出现多次方法调用。

注解:TearDown

注解
TearDown
的声明:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TearDown {
  Level value() default Level.Trial;
}

TearDown
用于方法上,与
Setup
的作用相反,
是基准测试后的操作
,同样使用
Level
提供了3个级别。

注解:Param

注解
Param
的声明:

@Inherited
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Param {
  
  String BLANK_ARGS = "blank_blank_blank_2014";

  String[] value() default { BLANK_ARGS };
}

Param
用于字段上,
用于指定不同的参数,需要搭配State注解来使用
。举个例子:

@State(Scope.Benchmark)
public class Test {
  @Param({"10", "100", "1000", "10000"})
  int count;

  @Benchmark
  @Warmup(iterations = 0)
  @BenchmarkMode(Mode.SingleShotTime)
  public void loop() throws InterruptedException {
    for(int i = 0; i < count; i++) {
      TimeUnit.MILLISECONDS.sleep(1);
    }
  }
}

上述代码测试了程序在循环10次,100次,1000次和10000次时的性能。

注解:Threads

注解
Threads
的声明:

@Inherited
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Threads {

  int MAX = -1;

  int value();  
}

Threads
用于方法和类上,
指定基准测试中的并行线程数
。当使用MAX时,将会使用所有可用线程进行测试,即
Runtime.getRuntime().availableProcessors()
返回的线程数。

注解:GroupThreads

注解
GroupThreads
的声明:

@Inherited
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface GroupThreads {
  int value() default 1;
}

GroupThreads
用于方法上,
指定基准测试分组中使用的线程数

注解:Fork

注解
Fork
的声明:

@Inherited  
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Fork {
  int BLANK_FORKS = -1;
  
  String BLANK_ARGS = "blank_blank_blank_2014";
  
  int value() default BLANK_FORKS;
  
  int warmups() default BLANK_FORKS;
  
  String jvm() default BLANK_ARGS;

  String[] jvmArgs() default { BLANK_ARGS };
  
  String[] jvmArgsPrepend() default { BLANK_ARGS };

  String[] jvmArgsAppend() default { BLANK_ARGS };
}

Fork
用于方法和类上,
指定基准测试中Fork的子进程

Fork
提供了6个参数:

  • value
    ,表示Fork出的子进程数量;
  • warmups
    ,预热次数;
  • jvm
    ,JVM的位置;
  • jvmArgs
    ,需要
    替换
    的JVM参数;
  • jvmArgsPrepend
    ,需要
    添加
    的JVM参数;
  • jvmArgsAppend
    ,需要
    追加
    的JVM参数。


Fork
设置为0时,JMH会在当前JVM中运行基准测试。由于可能处于用户的JVM中,无法反应真实的服务端场景,无法准确的反应实际性能,因此
JMH推荐进行
Fork
设置

另外可以利用
Fork
提供的JVM设置,将JVM设置为Server模式:

@Fork(value = 1, jvmArgsAppend = {"-Xmx1024m", "-server"})

注解:CompilerControl

注解
CompilerControl
的声明:

@Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE})  
@Retention(RetentionPolicy.RUNTIME)  
public @interface CompilerControl {  
 
  Mode value();  
   
  enum Mode {  
    BREAK("break"),
    PRINT("print"),
    EXCLUDE("exclude"),
    INLINE("inline"),
    DONT_INLINE("dontinline"),
    COMPILE_ONLY("compileonly");
  }  
}

CompilerControl
用于方法,构造器或类上,
指定编译方式
。其内部枚举类提供了6种编译方式:

  • BREAK
    ,将断点插入到编译后的代码;
  • PRINT
    ,打印方法及其配置;
  • EXCLUDE
    ,禁止编译;
  • INLINE
    ,使用内联;
  • DONT_INLINE
    ,禁止内联;
  • COMPILE_ONLY
    ,仅编译;

结语

关于JMH的使用,我们就聊到这里了,希望今天的内容能够帮助你学习并掌握一种更准确的性能测试方法。

最后提供一个练习使用JMH的思路:大家都看到了文章开头Caffeine给出的基准测试结果了,但由于是Caffeine作者自己提供的基准测试,难免有些“既当裁判又当选手”的嫌疑,或者说他选取了一些对Caffeine有利的角度来展示结果,那么可以结合你自己的实际使用场景,给Caffeine及其竞品做一次基准测试。


好了,今天就到这里了,Bye~~

作为一个非常古老(80后)的面向百度开发的程序员,我用百度非常多,大概在前几年的时候,搜技术关键字的时候,博客园上面的问题在百度首页出现的机会非常多,另外还有iteye这样的网站,但是在近几年发现越来越少了,首页基本上都是csdn的帖子,很多帖子都是无意义的复制,重复。虽然csdn的界面好看一点,但是内容却不敢保证,出现这个原因大概是因为百度2019年投资了开源中国,而开源中国和csdn又是一家。

这就造成了目前的局面,百度搜索的结果权重大部分都往csdn去了,而博客园的生存更加困难了 ,造成了部分技术博主的流失了。只留下一些有情怀的博主。

如果google能进入中国市场,那么博客园的权重会高一点。但是目前肯定是没有可能。

如果能搭上百度的顺风车 ,也许会好一点。

或者是soso的顺风车也可以。

现在是流量时代,需要跟搜索引擎合作。

随笔写一段。记录一下这个时代。毕竟,博客园的主子们都是有情怀的主子。

图片

最近有一些做程序化广告业务的朋友和公司找我咨询,他们很困惑十年前那么时兴的DSP和ADX,最近三四年怎么忽然就不香了,广告主预算给的不像原来多,考核要求还特别多、特别苛刻。他们问程序化广告业务还有没有未来呢?接下来他们该何去可从。

这是一个很好的话题,我想了想之后问他们怎么理解程序化广告,要解答他们的困惑首先就需要正确理解以下几个问题:

  • 怎么正确定义程序化广告?

  • 广告主预算变少了么?

  • 预算去了哪里?

  • 预算为什么要去那里?

本文写完之后发现一共有一万八千字左右,放在公众号传播不太合适,于是就按照文章脉络分为了五篇,每篇三千到五千字不等,本篇为第四篇。

连载完毕将会在一篇文章中发布全文。为了方便读者了解当前文章的上下文,全文大纲如下:

【本文大纲】

  • 一、什么是程序化广告?

  • 1.1 如何定义程序化广告?

  • 1.2 互联网广告交易模式进化

  • 1.3 程序化交易分类

  • 1.4 程序化广告参与角色

  • 二、过去这些年程序化广告领域都发生了什么?

  • 2.1 互联网演进广告时间轴

  • 2.2 广告主预算变少了么?

  • 2.3 预算去了哪里?

  • 2.4 预算都集中在哪些媒体类型?

  • 2.5 程序化广告变化趋势

  • 三、近几年程序化广告领域的变化底层逻辑是什么呢?

  • 3.1 古希腊城邦典型特征

  • 3.2 开放流量体系走向私有化

  • 3.3 封闭化媒体的开放平台

  • 3.4 对于程序化的冷静反思

  • 四、程序化广告还有希望么?

  • 五、我在程序化领域还能做什么呢?

  • 广告需求方

  • 需求方服务平台

  • 流量供应方

  • 六、结论

三、近几年程序化广告领域的变化底层逻辑是什么呢?

当前国内程序化生态的状态,更像是希腊的古典时代:古希腊时代的城邦高度繁荣的时期。很多人可能对古希腊城邦没有概念,我们解释一下:

所谓城邦就是城市国家,是以城市为中心、连带周边乡村而形成的独立国家,古希腊相继形成300多个城邦,这些城邦面积小,有时一个城市就是一个城邦,一个小岛就是一个城邦,人口也少,小国寡民是典型特征,居民彼此间共同生活、相互扶持,形成团体意识,画地为界,这就是我的城邦了。

图片

雅典和斯巴达是古典时代最重要的两个城邦,雅典以民主政治闻名于世,文化、经济都是诸多城邦里的佼佼者,被称为希腊的学校,而斯巴达(对,就是斯巴达勇士那个斯巴达)是寡头政治,民风尚武,军事力量强大。古希腊是一个地区,是一个民族,但不是一个国家,国家林立,古希腊文明就是由这300多个城邦组成的古文明,因为有相似的信仰,共同的语言、文字都加强了民族认同感与归属感。

3.1 古希腊城邦典型特征

  • 明确社会分工
    比如雅典就把公民分成贵族、农民和手工业者三个等级;比如斯巴达把战俘和被征服的土著居民定义为“希洛人”,希洛人就跟中国古时候的佃户一样,被固定在斯巴达人的土地上,居住在斯巴达人的庄外,土地交由希洛人耕种,收获后希洛人向主人缴纳谷物和乳酪。明确社会分工后会有利于技能的熟练和效率的提升,有利于物质的积累和剩余,为后续的交换打下物质基础。

  • 私有制
    有城墙和壁垒,城邦行政权都落入氏族贵族手中,城邦主通立法实现对城邦的管理

  • 开放
    有贸易,有交换,通过利益交换实现以物易物

  • 利益共同体
    不同城邦间会有合作形成联盟和交易

  • 可能也会有战争
    价值观不同的城邦间会有战争,价值观相同的城邦间会有联盟与和平

3.2 开放流量体系走向私有化

到这里我们想想当前的程序化广告的生态,是不是跟古希腊城邦很相似呢?

  • 广点通和字节是不是就很像雅典和斯巴达这样的大城邦,大城邦有强悍的基础设施和资金实力,有自有耕地——自有媒体,也有协助其他城邦交易的场所——广告联盟,为了有效控制交易流量都被放在大城邦的交易市场里内部交易(大厂内部投放系统),大城邦的(ADX)流量不再向外释放(或对接门槛特别高),寡头城邦们实现对中小城邦的盘剥和收税,实现从狩猎/采集为生到集体耕作的转变;

  • 而中小媒体和广告主就像是各有诉求的不知名小城邦,通过与雅典和斯巴达交易,实现自身的物资流转——卖量给广点通联盟和穿山甲联盟实现营收,通过腾讯广告和巨量引擎买量增长;

  • 城邦会保护自有公民——用户,每个城邦也会保护自有利益,通过向公民公共服务(内容或功能服务)向公民课税——付费或者是通过广告将用户导流给其他城邦(奴隶交易)。

  • 当然城邦间交易一定需要统一的度量衡(秦始皇统一七国首先要干的就是统一度量衡):程序化交易协议和规则,用户id匹配规范,共同遵守的技术标准(监测和计费标准)。

程序化广告作为一个提升广告交易效率的技术手段依然活着,但换了一种方式存活,RTB从原来人人都可以小商小贩在公共广场菜市场买卖,各家围起栅栏来形成自己的菜园子,在菜园子旁边划一块空地让广告主来这里交易,只卖自己的菜,或者跟自己关系好的合作方的菜。

图片

3.3 封闭化媒体的开放平台

媒体把过去在公开市场售卖的资源回收回来自售,同时也都一拥而上创建自己的开放平台:

  • 有的开放Marketing API让广告主批量创建计划和创意提升投放效率,让用户在自己的平台上花更多的广告预算;

  • 有的在自己的HeroApp之上创建小程序平台,微信有微信小程序,支付宝、百度、抖音也都开发了自己的小程序,连手机厂商们也联合起来搞了快应用。小程序、快应用其实都是这些媒体在自己的浏览器内核之上运行的符合媒体标准的网站,只是快应用更底层一些——只不过这些网站可以由广告主自己开发,也可以由媒体方开发,同时可以利用对应媒体的数据和功能;这些特殊网站的存在,有的是为了提供服务给大平台上的受众获取收益,有的是为了方便从大平台买量形成广告主自己的私域用户;

  • 有的开发了自己的内容管理平台,把传统的网站抽象为自己控制的内容管理平台,诸如微信开放了微信公众号,字节的今日头条有了头条号,传统的网站发布完,如果想要更多的受众就需要在这些内容管理平台上再发布一份,后续基于这些内容又可以有广告和内容分发的业务,围拢更多的用户和广告主,形成自己的流量生态;

3.4 对于程序化的冷静反思

我们无法去评价这种模式是好还是坏,就如同我们没有办法一定说,十年前中国轰轰烈烈的DSP热潮就一定是好的一样。因为那个时代DSP本来是一个很单纯的技术模式,这种模式改变了以往针对广告位买断模式的大水漫灌式广告投放,变成了针对受众和人群定向为目标的精细化广告采买模式。

本来是一个各方都可以获利的多赢局面,可惜各方自说自话,只看到自己眼前的利益:
各个DSP
花活太多,有以次充好欺骗广告主的,有的效果不行补量凑,花客户的钱买效果回收,有的靠着客情关系好,收钱不干活就说投完了的,有为了抢广告主预算能承诺和不能承诺的都敢忽悠广告主的;最终
广告主
被骗多次被惯坏之后,如渣男一般这也要那也要,劣币驱逐良币,广告预算可能更多的也被能说会道的DSP拿走了,最后一地鸡毛,广告主谁也不信了,自己采买DSP或者自建团队自建DSP;而
媒体
不管大小本来可以通过开放的广告交易市场充分售卖自己的流量,有的造假量无中生有,有的找第三方刷量公司刷量,有的收第三方的便宜量掺在自己的高价量里卖以次充好,有的刷归因劫持LastClick或者大点击小点击咔咔一通刷,最终行业相互之间不信任,变成了零和博弈。

3.4.1 短暂繁盛

公开广告程序化交易在中国的狂热而短暂的繁盛基本上持续了五年左右的时间,从2012年开始,到2016年宝马中国市场总监被实名举报,勾结多家DSP公司收受贿赂、数据造假,甚至是设置空壳公司为DSP供应商用以洗钱。看似是个针对个人的举报,但对于公开广告交易RTB成为了巨变的导火索,以往被吹捧的DSP和RTB成了众矢之的,变成业内刻意避讳的关键词,广告主在公开程序化交易方向的预算大面积缩减,包括可口可乐、万事达、葛兰素史克、联合利华都先后暂停了从代理商进行程序化购买,而代理商之前的投放也成了一笔烂账。

3.4.2 致命的黑盒存在

营销界里最有名的名言:我知道预算被浪费了一半,但我不知道是哪一半。在公开程序化交易中来讲可能远不止一般被浪费,在黑盒子里有可能浪费的是全部的预算。

价格黑盒

就如同前面《1.2.6 程序化广告交易价值分布》小节中,我们介绍了公开广告交易过程中,提到的只有一半左右的预算被放在ADX中参与竞拍流量。其实被黑盒的何止是广告价格本身,被黑盒化的还有投放过程。

投放黑盒

我们曾诟病包断广告,觉得它落后而没有技术含量,但对于广告主来讲在合同约定的指定时段里,总有一个位置必须放我的广告。

而公开交易的程序化的核心就是基于大数据的定向、精准营销,将广告投放给最合适的用户。本来是好意,但在一部分无良DSP来说,却成了投机取巧的遮羞布,给了钱不投放或者少投放,问起为啥看不到,精准定向啊,你不在特定受众范围内,给你一个窝心锤,哑巴吃黄连啊。还有一部分DSP自己的量不够就拿别人的低价量凑,时至今日大多数的第三方监测上报依然是api方式上报,所有的数据都是媒体方上报,中间是有很大的漏洞的,更别说什么群控、集群、秒拨IP、虚假流量和第三方监测绕过的大点击、小点击。

这也是为什么2018年宝洁联合一众大品牌,要求APP投放必须支持SDK形式的监测,由监测SDK主动收集环境信息,从源头保证数据准确性。除此之外,由于技术和网络原因,程序化服务商自有监测和第三方监测之间,天然会有一定的数据误差,误差范围在5%~10%是业界可接受的公允值,这也给DSP们预留了最高10%的利润空间。

广告主千防万防,也防不住一小撮广告程序化交易行业的败类,里面包括某些DSP,包括第三方监测。广告投放的过程中,为了避免被广告主察觉某些无良媒体还会上地域屏蔽,广告主所在的地域、办公区IP、LBS,捂住广告主的眼睛骗钱。

程序化广告在自动化技术提升的效率,在投放黑盒带来的广告主监管成本面前已经不算什么优点了。

3.4.3 恶性竞争

在公开广告RTB交易最火热的那个阶段,每一家都想做全生态,DSP公司同时拥有ADX、SSP、DMP、CDP和TradingDesk业务,尽管资本成功的案例不多,勉强虎口夺食突击IPO的程序化服务商们,也大多活的不太好,有的退市,有的摘牌,有的濒临退市;

反观国外除了Meta和Google这类巨头,很少有全生态覆盖的程序化服务商,获得好的基本都是在生态中精耕细作,在一个垂直领域里锤炼出一个精品。法国公司Criteo只做电商网服类再营销定向投放(Retargeting);美国TTD只做DSP SaaS服务,目前市值300亿美金。

国内的DSP在竞争最激烈那个阶段只是简单恶性竞争,疯狂造概念忽悠广告主,今天你有什么概念,明天我就抄过来,然后造一套更唬人的概念出来,没有精耕细作,没有稳扎稳打,缺少产品技术上持续的投入和打磨,有的就是不断忽悠不断片,相互间你追我赶通过造概念实现超越,没有门槛和护城河存在。

图片

接下来为了抢广告主预算,拼完概念就该拼价格了,别人要十个点服务费,我就能只要五个点;你能返点10%,我就翻翻,利润越摊越薄。为了挤死竞争对手,不断内卷,同时也缺乏像4A协会这样的行业协会统一服务标准和定价标准,人家4A公司好歹把客户费率约定在17.65%左右,价格上没啥可谈的了,在我这是这个比例,在竞争对手那边也是这个比例,你别跟我砍价了,卷也相互之间卷服务质量。在中国的程序化广告场景下,就变成了不为挣钱,交个朋友(画外音,不赚钱,难道你是为了爱情?)。
图片

3.4.4 资源困境

如果互联网行业里有对于广告生态有关键作用的那一定是:
广告主预算

流量

数据
。广告主是需求发起的来源,投什么不投什么,有什么诉求都从这里来,金主爸爸,没有预算就变成了无源之水无本之木;流量是传播途径,广告主用于触达用户的基础;而数据是效果优化的基础,没有相关数据的积累,广告主预算如泥牛入海一般砸不出什么水花。

程序化服务商的无奈在于,三样核心资源,没有一样是控制在他们手里的。
他们去做全生态业务,一方面是出于野心,步子跨太大;另外一方面在没有控制核心资源的情况下,总要给自己找点生存空间。
图片

广告主预算
是相对公允的,广告主趋利避害,预算流动的方向一定是找到的更有性价比的流量。谁的流量能带来转化,谁的ROI更高就把更多预算切到这个渠道上,最终考验的是流量的匹配程度和广告产品技术的精细化运营能力。

在进入存量博弈时代,
流量
的稀缺性一定程度上已经高于广告主,可以是当下最重要的资源;而这些最重要的资源却牢牢掌握在巨头和大型媒体手中。在公开RTB交易可以交保证金就能接入的时候,巨头们表面上开放了上百亿的流量,但他们也有自己的广告联盟和投放系统,
外部第三方DSP只是一个捡垃圾的“二等公民”,采量优先级一定是低于巨头的自有平台的
。百度在最高峰时通过BES开放了数十亿的流量,同时期外部DSP的消耗最高值也就100-200万每天,而当时百度联盟日消耗3000万以上。大量的流量只有首先满足了内部的需要,外部DSP能获得的流量只能是吃剩的残羹冷炙,投放效果在数据不如巨头的情况下投的比巨头好这就有点难度了,我们前面也说了很多第三方DSP都是玩概念和压级压价的高手,当然是对自己开枪。

公开RTB交易的程序化对于巨头来讲,开放资源所带来的额外收益并不明显,所以也让资源方失去了继续大面积对外开放的动力。比如百度BES开放给第三方DSP的流量目的是为了收益最大化,发送给第三方DSP的流量费用也不在少数,数据有限的情况下,DSP填充率比较低,更有可能入不敷出。

除了巨头,稍有一定流量的大媒体受众的流量完全可以自己售卖,第三方DSP只能是吃一下残羹冷炙;优质位置和流量,可以通过私有交易、包断采买等方式优先卖给匹配的优质广告主,此处的溢价空间远高于开放竞价所带来的收益提升,优质广告主也乐于接受投放结果和价格都透明的流量。部分程序化服务商开始做起了媒体代理业务,吃返点并以此拥有部分媒体的控制权;

数据的困境
也是扼住程序化服务商咽喉的关键之一。在监管不严的2015年,电信运营商愿意开放底层的DPI(Deep Packet Inspection)数据——也就是运营商骨干路由镜像出来的用户流量数据可以用来分析用户的喜好,清洗完的数据可以生成特定兴趣标签的人群包,供给DSP实现精准定向;银行卡、POS机终端厂商和各类程序化服务商也在不断尝试,如何将用户数据不泄露、脱敏的情况下,完成用户消费数据赋能广告投放。但事实上这类数据属于运营商,运营商要把这份数据给谁就能提升哪个DSP的转化,自然是要待价而沽,卖个好价钱了,那价格自然也不会低,如果要用也是有大金主广告主愿意投入一次性买个人群包出来在自己的DSP上投放,但这个人群包跟手头接入流量的匹配程度又能有多少又是一个未知数了,很大可能是交集很小,最终也只能无果而终了。再加上近两年国家对隐私保护监管政策的收紧,谷歌和苹果对设备标识物政策的改变。程序化精准营销最为依赖的用户ID和行为数据,在iOS 14.5之后无法直接拿到idfa,如果要需要用户显式授权,AndroidQ之后也无法随意拿到imei了,以往精准的用户id在此时就需要用更模糊的方式代替了。

三项核心资源没有一样是程序化服务商们能有效控制的,那你这买卖还能长久么?岂不是手拿把掐让人拿捏的份嘛。

程序化交易本身的初衷是产业升级,实现流量的高效售卖,再迁移到国内却发现水土不服,公开交易市场的RTB已经接近名存实亡:

  • 内部原因:程序化服务商恶性竞争、缺乏行业组织进行约束和自律;

  • 外部原因:没有掌握核心资源,外加政府监管细化,谷歌和苹果设备标识物权限收紧;

  • 根本原因:价格黑盒和投放黑盒导致各方利益不均衡;