第一章:Go语言弹幕系统架构全景概览
现代高并发弹幕系统需兼顾低延迟、高吞吐与强一致性,Go语言凭借其轻量级协程(goroutine)、高效网络栈和原生并发模型,成为构建此类系统的理想选择。本章呈现一个生产就绪的弹幕系统核心架构视图,涵盖服务分层、数据流向与关键组件职责。
核心分层设计
系统采用清晰的四层结构:
- 接入层:基于
net/http与gorilla/websocket实现 WebSocket 长连接网关,支持每秒万级连接建立; - 逻辑层:无状态弹幕处理服务,使用
sync.Map缓存房间活跃连接,通过 channel 批量分发弹幕事件; - 存储层:双写策略——热数据存于 Redis Sorted Set(按时间戳排序),冷数据异步落盘至 PostgreSQL(含
room_id,user_id,content,created_at字段); - 推送层:基于
gorilla/websocket的广播池,每个房间维护独立map[*websocket.Conn]bool,避免全局锁竞争。
关键通信机制
弹幕消息在各层间以结构化 JSON 流动,典型 payload 如下:
{
"event": "DANMU",
"room_id": "1001",
"user": {"id": "u_789", "nick": "GoFan"},
"content": "太强了!",
"timestamp": 1717023456123
}
服务启动时初始化 Redis 连接池(最大连接数设为 runtime.NumCPU()*4),并通过 redis.NewClient() 显式配置超时与重试策略。
性能保障策略
| 维度 | 实践方式 |
|---|---|
| 连接复用 | WebSocket 连接生命周期绑定 goroutine,避免频繁创建/销毁 |
| 消息限流 | 每用户每秒最多 5 条弹幕,基于 Redis INCR + EXPIRE 实现 |
| 故障隔离 | 房间维度熔断:单房间错误率超 15% 自动降级为只读模式 |
该架构已在日均 2000 万弹幕峰值场景中稳定运行,平均端到端延迟低于 120ms。
第二章:WebSocket弹幕服务核心实现与高可用设计
2.1 基于gorilla/websocket的低延迟连接管理与心跳保活实践
WebSocket 连接易受网络抖动、NAT超时或代理中断影响,需兼顾低延迟与强可靠性。
心跳机制设计原则
- 客户端主动
ping(无载荷),服务端立即pong响应 - 超时阈值设为
30s,连续 2 次失败即关闭连接 - 使用
SetPingHandler和SetPongHandler替代轮询,避免 goroutine 泄漏
关键配置代码
conn.SetPingPeriod(25 * time.Second)
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
conn.SetReadLimit(512 * 1024)
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
return nil
})
SetPingPeriod控制服务端自动发送 ping 间隔;SetReadDeadline在每次 pong 后刷新读超时,实现双向活性检测;SetReadLimit防止恶意大数据帧耗尽内存。
连接生命周期对比
| 策略 | 平均恢复延迟 | NAT 兼容性 | 实现复杂度 |
|---|---|---|---|
| TCP Keepalive | 60–120s | 弱 | 低 |
| 应用层心跳(本方案) | 强 | 中 |
graph TD A[客户端发起连接] –> B[服务端设置 Ping/Pong Handler] B –> C[周期性 Ping 触发] C –> D{收到 Pong?} D — 是 –> E[重置读超时,连接活跃] D — 否 –> F[标记异常,触发关闭]
2.2 弹幕消息协议设计:二进制帧结构、序列化选型(Protocol Buffers vs MsgPack)与压缩策略
弹幕系统需在毫秒级延迟下承载百万级并发写入,协议设计直接决定吞吐与端侧解析效率。
二进制帧结构
采用固定头部 + 可变负载设计:
// Frame.proto
message DanmuFrame {
uint32 magic = 1 [default = 0x444D5501]; // "DMU\x01"
uint16 version = 2 [default = 1];
uint16 payload_type = 3; // 1=chat, 2=gift, 3=system
uint32 payload_len = 4;
bytes payload = 5; // serialized DanmuMessage
}
magic用于快速校验与字节序识别;payload_len避免流式解析时的粘包风险;payload_type支持未来协议扩展。
序列化对比关键指标
| 维度 | Protocol Buffers | MsgPack |
|---|---|---|
| 典型体积 | 低(强 schema) | 中 |
| JS 解析性能 | 需生成绑定代码 | 原生快 |
| 动态字段支持 | 弱(需 Any) | 强 |
压缩策略
启用 LZ4 帧级压缩(非全局),仅对 payload_len > 128 的帧触发,压缩后仍保留原始 payload_len 字段以兼容未升级客户端。
2.3 并发模型剖析:MPSC通道分发、读写分离连接池与goroutine泄漏防护
数据同步机制
采用 MPSC(Multiple-Producer, Single-Consumer)无锁通道实现事件分发,避免竞争与锁开销:
// ch 为 buffered channel,容量 1024,生产者并发写入,消费者单 goroutine 拉取
ch := make(chan *Event, 1024)
// 生产者侧(无锁写入)
go func() { ch <- &Event{Type: "log"} }()
go func() { ch <- &Event{Type: "metric"} }()
// 消费者侧(串行处理,保障顺序性)
for e := range ch { process(e) }
逻辑分析:chan *Event 利用 Go 运行时的 FIFO 调度保证消费顺序;缓冲区缓解突发流量;指针传递避免内存拷贝。1024 容量在延迟与内存间取得平衡——过小易阻塞,过大增 GC 压力。
连接池策略
| 维度 | 读池 | 写池 |
|---|---|---|
| 连接数上限 | 32 | 8 |
| 空闲超时 | 5m | 30s |
| 用途 | 查询密集型 | 强一致性写入 |
泄漏防护
启用 pprof + runtime.SetFinalizer 双重监控,并在连接 Close 时触发 goroutine 计数器减量。
2.4 分布式状态同步:Redis Streams作为广播总线与本地内存缓存一致性保障
数据同步机制
Redis Streams 天然支持多消费者组(Consumer Group)与消息持久化,可作为轻量级、有序、可回溯的广播总线。服务实例订阅同一 stream,接收状态变更事件(如 user:1001:profile_updated),触发本地 Caffeine 缓存刷新。
一致性保障策略
- ✅ 消息幂等处理(基于 event ID 去重)
- ✅ 本地缓存失效前先校验版本号(
XADD ... ver=123) - ❌ 禁止直接覆盖未确认消息
示例:事件消费与缓存更新
// 使用 Jedis + Stream consumer group
String groupId = "cache-invalidator";
String streamKey = "events:state";
jedis.xgroupCreate(streamKey, groupId, "0", true); // 创建消费者组
List<Map.Entry<String, List<StreamEntry>>> messages =
jedis.xreadGroup(groupId, "worker-1",
Map.of(streamKey, ">"), 1, 1000); // 读取新消息
">"表示仅拉取未分配消息;xreadGroup自动 ACK,配合XACK可实现至少一次语义;1000ms超时避免长阻塞。需在业务逻辑中解析StreamEntry的Map<String,String>字段提取key和new_value。
关键参数对比
| 参数 | 推荐值 | 说明 |
|---|---|---|
XGROUP CREATEMKSTREAM |
启用 | 自动创建 stream |
AUTOCLAIM 间隔 |
≤5s | 防止消费者宕机导致消息积压 |
| 本地缓存 TTL | Long.MAX_VALUE |
仅依赖事件驱动失效 |
graph TD
A[状态变更事件] --> B[Redis Stream]
B --> C{Consumer Group}
C --> D[Service Instance 1]
C --> E[Service Instance 2]
D --> F[本地缓存失效]
E --> G[本地缓存失效]
2.5 连接生命周期治理:优雅关闭、断线重连语义、客户端会话上下文透传
连接不是“建立即遗忘”的静态资源,而是需全程可观测、可干预、可延续的有状态契约。
优雅关闭的三阶段协同
应用发起 close() 后需等待:
- 网络层完成 FIN/ACK 握手
- 应用层清空待发送缓冲区(如 Netty 的
flush()+closeFuture()) - 服务端确认会话上下文已归档(如 Redis 中
session:uuid:stateTTL 延长 30s)
channel.closeFuture().addListener(future -> {
if (future.isSuccess()) {
sessionContext.persist(); // 透传的 clientID、authToken、lastActiveTs 写入持久化层
}
});
逻辑分析:
closeFuture()非阻塞监听连接物理断开事件;persist()将内存中携带的客户端上下文(含设备指纹、租户ID、灰度标签)原子写入,支撑断线后精准恢复会话状态。
断线重连语义分级表
| 级别 | 触发条件 | 上下文恢复方式 |
|---|---|---|
| L1 | 网络瞬断( | 复用本地 channel 缓存 |
| L2 | 进程重启 | 携带 reconnect_token 重建会话 |
| L3 | 账户登出/过期 | 拒绝重连,强制跳转登录页 |
会话上下文透传流程
graph TD
A[客户端 connect] --> B{携带 header:<br>client-id, auth-token,<br>session-ttl, trace-id}
B --> C[网关解析并注入 MDC]
C --> D[下游服务通过 ThreadLocal<br>或 gRPC metadata 透传]
D --> E[日志/指标/DB 分片键自动绑定 client-id]
第三章:零downtime灰度升级机制深度落地
3.1 基于Kubernetes Service Mesh的流量染色与版本路由(Istio VirtualService实操)
流量染色(Traffic Coloring)是灰度发布的核心能力,通过请求头注入标签(如 x-env: canary),结合 Istio 的 VirtualService 实现细粒度版本路由。
染色策略与路由匹配逻辑
Istio 依据 HTTP 头、路径、权重等条件分流。关键在于将用户标识(如 Cookie 或 Header)映射到目标子集(subset)。
示例:基于请求头的金丝雀路由
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: productpage
spec:
hosts:
- productpage
http:
- match:
- headers:
x-env:
exact: "canary" # 染色标识:请求头必须精确匹配
route:
- destination:
host: productpage
subset: v2 # 路由至 v2 子集(需对应 DestinationRule)
- route:
- destination:
host: productpage
subset: v1 # 默认路由至稳定版 v1
逻辑分析:该配置优先匹配含
x-env: canary的请求,强制导向v2;其余所有流量降级至v1。subset必须在DestinationRule中预先定义,且关联对应 Kubernetes Service 的version标签(如app: productpage, version: v2)。
流量染色典型场景对比
| 场景 | 染色方式 | 适用阶段 |
|---|---|---|
| 内部测试 | 自定义 Header | 验证环境 |
| AB测试 | Cookie 键值匹配 | 生产灰度 |
| 用户分群路由 | JWT claim 提取 | 多租户系统 |
graph TD
A[客户端请求] --> B{是否携带 x-env: canary?}
B -->|是| C[路由至 productpage-v2]
B -->|否| D[路由至 productpage-v1]
C & D --> E[响应返回]
3.2 弹幕服务双写兼容模式:新旧协议并行解析与平滑协议迁移方案
为保障百万级并发弹幕场景下的零停机升级,系统采用双写兼容模式,在接收层同时解析旧版 JSON 协议与新版 Protobuf 协议。
数据同步机制
接收网关对每条弹幕请求执行双路径分发:
- 主路径:按
Content-Type头识别协议类型,路由至对应解析器; - 旁路:强制将原始字节流同步写入 Kafka 的
danmu-raw-v1和danmu-raw-v2双 Topic。
def parse_danmu(raw: bytes, content_type: str) -> Dict:
if "application/protobuf" in content_type:
return ProtoParser.parse(raw) # 解析 v2 Schema,含 timestamp_ns、uid_hash 等字段
else:
return JsonParser.parse(raw) # 兼容 v1,仅支持 ms 级时间戳与明文 uid
ProtoParser.parse() 输出结构化字典,timestamp_ns 为纳秒级单调递增时间戳,uid_hash 是 64 位 FNV-1a 哈希值,用于防刷;JsonParser.parse() 保留 time_ms 和 user_id 字符串字段,确保旧客户端无缝接入。
协议路由决策表
| 条件 | 路由动作 | 降级策略 |
|---|---|---|
content_type == "application/protobuf" |
写入 v2 Topic + 更新 v2 索引 | 同时 fallback 至 v1 解析日志告警 |
content_type == "application/json" |
写入 v1 Topic + 触发 v2 模拟解析 | 记录 schema 转换延迟(P99 |
graph TD
A[HTTP Request] --> B{Content-Type}
B -->|protobuf| C[ProtoParser → v2 Schema]
B -->|json| D[JsonParser → v1 Schema]
C --> E[Kafka: danmu-raw-v2]
D --> F[Kafka: danmu-raw-v1]
C --> G[同步触发 v1 兼容日志]
3.3 升级过程可观测性:Prometheus指标埋点(连接数/丢包率/端到端延迟P99)与自动熔断阈值配置
为保障灰度升级期间服务稳定性,需在关键网络代理层(如 Envoy 或自研网关)注入三类核心指标:
指标埋点示例(Go 语言)
// 初始化 Prometheus 注册器与直方图(端到端延迟 P99)
var (
connGauge = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "upgrade_connection_total",
Help: "Current number of active connections during upgrade",
},
[]string{"stage", "region"},
)
lossRate = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "upgrade_packet_loss_ratio",
Help: "Packet loss ratio per upstream cluster (0.0–1.0)",
},
[]string{"upstream"},
)
latencyHist = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "upgrade_end2end_latency_ms",
Help: "P99 end-to-end latency in milliseconds",
Buckets: prometheus.ExponentialBuckets(10, 2, 8), // 10ms–1280ms
},
[]string{"direction"},
)
)
逻辑说明:
connGauge实时反映各升级阶段连接负载;lossRate以浮点形式上报链路丢包率(由 ICMP+UDP 探针聚合计算);latencyHist使用指数桶覆盖典型微服务延迟分布,确保 P99 可通过histogram_quantile(0.99, rate(...))精确提取。
自动熔断触发条件(YAML 配置)
| 指标 | 熔断阈值 | 持续时间 | 动作 |
|---|---|---|---|
upgrade_connection_total{stage="canary"} |
> 5000 | 60s | 暂停新实例扩容 |
upgrade_packet_loss_ratio{upstream="auth"} |
> 0.05 | 30s | 切流至旧版本 |
rate(upgrade_end2end_latency_ms_bucket{direction="out"}[5m]) |
P99 > 800ms | 120s | 启动降级预案 |
熔断决策流程
graph TD
A[采集指标] --> B{P99延迟 > 800ms?}
B -->|是| C[检查丢包率是否超阈值]
B -->|否| D[维持当前策略]
C -->|是| E[触发熔断:切流+告警]
C -->|否| F[仅记录异常事件]
第四章:弹性扩缩容策略与自动化运维体系
4.1 自定义HPA指标采集:基于Redis Pub/Sub QPS与Go runtime GC pause构建扩缩容信号源
数据同步机制
通过 Redis INFO commandstats 提取 pubsub 类命令的调用频次,结合 Go runtime.ReadMemStats() 中 PauseTotalNs 与 NumGC 计算单位时间 GC 暂停均值(ms),双指标加权融合为复合信号。
指标采集代码示例
// 从 Redis 获取 pubsub QPS(近似值)
qps, _ := redisClient.Do(ctx, "INFO", "commandstats").Text()
// 解析: cmdstat_publish:calls=12345,usec=678900,usec_per_call=55.0
该调用依赖 Redis 的实时统计能力,usec_per_call 反映消息分发延迟,calls 差分后除以采样间隔得 QPS;需配置 --redis.timeout=5s 避免阻塞。
复合指标权重表
| 指标类型 | 权重 | 触发阈值 | 灵敏度 |
|---|---|---|---|
| Redis Pub/Sub QPS | 0.6 | > 800 | 高 |
| GC Pause (ms) | 0.4 | > 12 | 中 |
扩容决策流程
graph TD
A[采集QPS & GC Pause] --> B{QPS > 800?}
B -->|Yes| C[触发扩容]
B -->|No| D{GC Pause > 12ms?}
D -->|Yes| C
D -->|No| E[维持副本数]
4.2 水平扩缩容决策模型:预测式(ARIMA时序预测)+响应式(CPU+连接数双维度)混合策略
传统单阈值扩缩容易引发抖动。本方案融合长期趋势预判与实时负载反馈,提升决策稳定性。
双通道决策逻辑
- 预测通道:每日凌晨用历史 CPU 使用率训练 ARIMA(1,1,2) 模型,滚动预测未来30分钟负载基线
- 响应通道:每15秒采集
container_cpu_usage_seconds_total与nginx_connections_active,触发双条件判定
ARIMA 预测核心代码
from statsmodels.tsa.arima.model import ARIMA
# data: 5min-interval CPU usage (last 72h), shape=(864,)
model = ARIMA(data, order=(1, 1, 2)) # p=1自回归, d=1差分, q=2滑动平均
forecast = model.fit().forecast(steps=6) # 预测未来6个5min窗口(即30min)
order=(1,1,2)平衡收敛性与过拟合风险;steps=6对齐K8s HPA最小评估周期;输出为标准化负载比(0.0–1.0),用于设定动态扩缩容基线。
决策矩阵(触发条件)
| CPU使用率 | 连接数偏离预测基线 | 动作 |
|---|---|---|
| > 75% | — | 立即扩容 |
| > 60% | > +40% | 提前扩容 |
| 延迟缩容(防抖) |
graph TD
A[Metrics Collector] --> B{CPU > 75%?}
B -->|Yes| C[Scale Up Now]
B -->|No| D{Conn Δ > +40%?}
D -->|Yes| E[Scale Up Early]
D -->|No| F[Hold & Monitor]
4.3 弹幕节点自愈机制:Pod就绪探针增强(WebSocket握手健康检查)、连接迁移协调器设计
传统 httpGet 就绪探针无法捕获 WebSocket 握手失败或长连接僵死问题。我们升级为自定义 exec 探针,主动发起轻量级 WebSocket 握手验证:
# /healthz-websocket.sh
curl -sf -o /dev/null \
--header "Connection: Upgrade" \
--header "Upgrade: websocket" \
--header "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
--header "Sec-WebSocket-Version: 13" \
http://localhost:8080/danmaku/ws?health=1 \
&& echo "ok" || exit 1
逻辑分析:脚本模拟客户端发起标准 WebSocket 升级请求(RFC 6455),仅校验 HTTP 101 响应与
Sec-WebSocket-Accept头存在性,耗时 ?health=1 触发服务端跳过鉴权与会话初始化,避免探针污染真实连接池。
连接迁移协调器设计
当 Pod 被标记为 Terminating 时,协调器通过 Kubernetes API 监听事件,并触发三阶段迁移:
- 广播迁移通告(gRPC 流推送到所有在线边缘节点)
- 暂停新连接接入(修改 Service Endpoints 对应的
readinessGates) - 原地优雅驱逐(向 Pod 发送
SIGUSR2,触发 Danmaku Server 主动关闭 WebSocket 连接并同步最后心跳状态至 Redis)
健康检查策略对比
| 探针类型 | 检测维度 | 僵死连接识别 | RTT 开销 | 实现复杂度 |
|---|---|---|---|---|
httpGet |
HTTP 状态码 | ❌ | ~20ms | ⭐ |
tcpSocket |
TCP 连通性 | ❌ | ~5ms | ⭐ |
| 自定义 WebSocket | 协议层握手有效性 | ✅ | ~120ms | ⭐⭐⭐ |
graph TD
A[Pod 启动] --> B{就绪探针执行}
B --> C[发起 WebSocket 握手请求]
C --> D{HTTP 101 + Sec-WebSocket-Accept?}
D -->|是| E[标记为 Ready]
D -->|否| F[重试 3 次后标记 NotReady]
4.4 全链路压测与容量基线建模:wrk2模拟百万并发弹幕流+火焰图定位goroutine阻塞瓶颈
为真实复现直播高并发弹幕场景,我们采用 wrk2 替代传统 wrk,通过恒定吞吐量模式精准施压:
wrk2 -t100 -c5000 -d300s -R100000 \
-s ./scripts/danmu_post.lua \
--latency "http://api.danmu.svc.cluster.local/v1/push"
-R100000:强制每秒发起 10 万请求(非峰值,是稳定速率),逼近百万级/分钟弹幕洪流-s ./scripts/danmu_post.lua:Lua 脚本注入随机 UID、弹幕内容及时间戳,规避服务端缓存与幂等拦截
压测中 pprof 火焰图暴露出 runtime.gopark 占比超 68%,进一步定位到 sync.Mutex.Lock 在弹幕广播协程池中的串行争用。
关键阻塞点对比(采样自 pprof)
| 模块 | 占比 | goroutine 状态 | 根因 |
|---|---|---|---|
broadcastLoop |
42% | semacquire1 |
广播队列锁竞争 |
redis.Write |
19% | netpollblock |
Redis 连接写缓冲满 |
优化路径示意
graph TD
A[wrk2 恒定RPS压测] --> B[pprof CPU/trace采集]
B --> C{火焰图热点分析}
C -->|gopark高占比| D[定位 sync.Mutex 临界区]
C -->|netpollblock聚集| E[升配 Redis 连接池+启用 pipeline]
D --> F[改用无锁 RingBuffer + worker-sharding]
第五章:性能基准测试报告与生产调优清单
测试环境与工具链配置
本次基准测试基于 Kubernetes v1.28 集群(3 master + 6 worker,均搭载 AMD EPYC 7452 @ 2.35GHz、128GB RAM、NVMe RAID0),应用为 Spring Boot 3.2 微服务(JDK 21.0.3+9-LTS,G1GC 默认参数)。压测工具采用 k6 v0.48.0(分布式模式,6个负载节点),监控栈为 Prometheus 2.47 + Grafana 10.2 + eBPF-based bpftrace 实时追踪。所有测试均在业务低峰期执行,持续时间≥30分钟,warm-up 阶段严格隔离。
核心指标对比表(TPS / P99 延迟 / 错误率)
| 场景 | 并发用户数 | TPS | P99 延迟(ms) | 错误率 | CPU 平均使用率 |
|---|---|---|---|---|---|
| 初始配置 | 200 | 412 | 1,284 | 0.8% | 76% |
| 启用 JIT 编译优化后 | 200 | 596 | 832 | 0.1% | 62% |
| 启用 G1MaxPauseMillis=200 + -XX:G1HeapRegionSize=4M | 200 | 683 | 617 | 0.0% | 54% |
| 加入 Netty 直接内存池预分配(-Dio.netty.allocator.type=pooled) | 200 | 741 | 529 | 0.0% | 49% |
JVM 关键调优项实录
-XX:+UseG1GC -XX:MaxGCPauseMillis=200:将 GC 暂停从平均 412ms 压降至 89ms;-XX:G1HeapRegionSize=4M:避免大对象频繁触发 Humongous Allocation,减少 Full GC 次数(由 3.2 次/小时降至 0);-XX:+UnlockDiagnosticVMOptions -XX:+PrintGCDetails -Xlog:gc*:file=/var/log/jvm/gc.log:time,tags:filecount=5,filesize=100M:启用结构化 GC 日志用于自动分析;- 移除
-XX:+UseStringDeduplication:实测导致 CMS 周期延长,在 G1 下收益为负(+1.7% CPU 开销,无延迟改善)。
数据库连接池深度调优验证
HikariCP 连接池参数经 12 轮 A/B 测试确认最优组合:
spring:
datasource:
hikari:
maximum-pool-size: 32 # 非 64 —— 超过 32 后 DB 端锁争用陡增(PostgreSQL pg_stat_activity 显示 wait_event = 'Lock' 占比升至 37%)
minimum-idle: 8
connection-timeout: 3000
idle-timeout: 600000
max-lifetime: 1800000
网络栈内核参数加固
在所有 worker 节点执行以下 sysctl 持久化配置:
net.core.somaxconn = 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = "1024 65535"
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
fs.file-max = 2097152
实测使单节点可稳定承载 32k HTTP/1.1 长连接(对比默认值仅 8k)。
生产灰度发布调优路径
graph LR
A[上线前] --> B[开启 JVM Flight Recorder 采集 5 分钟]
B --> C[注入 5% 流量至新版本 Pod]
C --> D{P99 延迟 ≤ 当前主干 110%?}
D -->|是| E[提升流量至 20%]
D -->|否| F[回滚并检查 GC 日志与堆直方图]
E --> G[全量切流 + 持续监控 1 小时] 