Call-ID 加两个 tag,三样东西拼起来才是一次会话——少一样就配不上。 而 CSeq 的规矩里有一条例外:ACK 不递增。 这一条例外造成的 481,是 SIP 排障里最常见的一个码。
09.01.02 说了「对话」这一层管整通通话,这一节讲它靠什么标识。
01.04.04 讲 RTSP 时也有 Session 号维系状态,SIP 没有那种会过期的会话号,
它用的是「三样拼起来」的办法。
一、三元组
| 成分 | 谁生成 | 在哪 |
|---|---|---|
| Call-ID | 主叫 | Call-ID 头 |
| local tag | 自己 | From 头 |
| remote tag | 对方 | To 头 |
三样都要有,而且「对同一通电话来说,两边算出来的应该一样」:
主叫侧的 local 就是被叫侧的 remote,反过来也一样。
这就是为什么它叫「对话标识」:它是个「从哪边看都一致」的组合。
第二行那个 tag 只有发起方生成,
被叫方的 tag 是在第一个应答里带回来的(To 头会多一个 tag=)。
所以「对话建立」的准确时刻是:主叫收到带 To tag 的 1xx 或 2xx。
在那之前,这通电话还只是「一个事务」。
二、tag 从哪来,能不能自己定
规范只要求「全局唯一」,没规定怎么生成:
| 做法 | 常见度 | 风险 |
|---|---|---|
| 随机十六进制 | 最常见 | 低 |
| 固定值 | 测试时用 | 并发会串 |
| 按时间戳 | 少 | 同毫秒会撞 |
第二行那种是测试专用:固定 tag 让报文可复现, 但绝对不能上生产——两通并发电话撞了同一个 tag,服务端会把它们当成同一通。 第三行那个「同毫秒会撞」是真事:高并发下每秒几千通电话,毫秒级时间戳根本不够用。 工程上的做法是「随机 + 计数器」组合,保证同一进程内不重复、跨进程靠随机。
三、CSeq 的规矩与那条例外
规则很短:非 ACK 的请求,每发一次加一;ACK 沿用被确认那个的号。
| 顺序 | 方法 | CSeq | 规则 |
|---|---|---|---|
| 1 | INVITE |
1 | 起始值 |
| 2 | ACK |
1 | 不递增 |
| 3 | re-INVITE | 2 | 递增 |
| 4 | ACK |
2 | 不递增 |
| 5 | BYE |
3 | 递增 |
第二、四行就是那条例外:ACK 属于它确认的那个 INVITE 事务,所以号必须一样。 加了会怎样? 服务器回 481(Call/Transaction Does Not Exist)——因为它找不到「CSeq 是 2 的那个 INVITE」。 481 这个码在 SIP 排障里的地位,相当于 HTTP 的 404:它基本只说明「你给的标识我这儿没有」。 所以看到 481,第一反应应该是查 CSeq 与 branch,而不是查网络。
四、一个对话里有多个事务
这是最容易在日志里看晕的地方:
| 东西 | 这通电话里有几个 | 标识 |
|---|---|---|
| 对话 | 1 个 | Call-ID + 两 tag |
| 事务 | 3 个以上 | branch |
| 消息 | 8 条左右 | 不标识 |
第二行那个「3 个以上」:INVITE、ACK、BYE 各一个,re-INVITE 再来两个。 所以日志里同一通电话会看到好几个 branch,它们是「同一个对话里的不同事务」。 排障时的正确顺序是:先用 Call-ID 把这一通电话的所有报文捞出来,再按 CSeq 排序看顺序, 最后才逐个事务看 branch 有没有对上。 反过来从 branch 入手会漏报文——因为一个 branch 只对应一对请求应答。
五、挂断之后对话就没了
这一点常被忽略,但它影响程序设计:
| 时刻 | 对话还在吗 | 能不能再发请求 |
|---|---|---|
| 200 OK 之后 | 在 | 能(re-INVITE) |
| BYE 之后 | 没了 | 不能 |
| 超时之后 | 没了 | 不能 |
第二行要记牢:BYE 一发出,这个 Call-ID + tag 的组合就作废了,再用它发请求会得到 481。
所以「断线重连」必须重新 INVITE、重新生成 Call-ID,不能拿旧的接着用。
第三行那个「超时」也值得一提:SIP 没有像 RTSP 那样的 Session 超时头,
对话什么时候算消失,靠的是「双方都不再发请求」这个事实。
所以有些实现会定期发 re-INVITE 保活对话。
六、动手 Demo:三元组怎么把两条报文认成一次会话
Demo 用固定 Call-ID 与 tag 拼两条报文,证明「参数一样报文就一样」,再算一遍 CSeq 序列。
a = S.build_req("INVITE", cseq=1, callid="[email protected]",
from_tag="aaa111")
b = S.build_req("INVITE", cseq=1, callid="[email protected]",
from_tag="aaa111")
print(a == b, len(a.encode()))
n = 0
for m in ("INVITE", "ACK", "INVITE", "ACK", "BYE"):
if m != "ACK":
n += 1
print(m, n)
实跑输出:
--------------------------------------------------------------------
三元组怎么把两条报文认成一次会话
--------------------------------------------------------------------
**SIP 的会话标识是三样东西拼起来的,缺一样就配不上**:
成分 本例 谁生成
-----------------------------------------------------
Call-ID [email protected] **全局唯一**
local tag aaa111 From 里那个
remote tag —(响应里才有) To 里那个
**两次拼出来的报文一模一样**(361 字节),
**因为 Call-ID 与 tag 都是显式给的**——**这就是「可复现」的关键:标识不能靠随机数。**
**真实客户端里 Call-ID 是随机生成的**,**但测试时必须能固定**,**否则同一个 bug 复现不了。**
**看一个真实的对话里 CSeq 怎么走**:
顺序 方法 CSeq 规则
----------------------------------------
1 INVITE 1 递增
2 ACK 1 **不递增**
3 INVITE 2 递增
4 ACK 2 **不递增**
5 BYE 3 递增
**第三列那个「ACK 不递增」是硬规矩**:**ACK 的 CSeq 必须和它确认的那个 INVITE 一样**,
**因为 ACK 属于同一个事务。**
**所以「ACK 的 CSeq 加一」是初学者最常犯的错**——**加了之后设备回 481(事务不存在)。**
**481 这个码要记住**:**它基本只出现在「CSeq 或 branch 对不上」的时候。**
第一行那个 True 是这一节的基础:
标识固定,报文就完全可复现——这是所有 SIP 测试脚本的前提。
如果 Call-ID 用随机数,同一条命令跑两次报文就不一样,
而「不一样」会让「对比两次抓包」这种最常用的排障手段失效。
第二段那个循环是 CSeq 规则的直译:if m != "ACK" 就是那条例外,
去掉这个判断,ACK 也会递增,结果全错。
输出会是 1、1、2、2、3——两个 1 和两个 2 就是「INVITE 与它的 ACK 同号」。
这一段代码只有五行,但它是「为什么 ACK 会回 481」的完整解释:
多加了 1,服务器就在自己的事务表里找不到那个号。
30 秒实验
- 把 Call-ID 改成随机数,看跑两次的字节数还一不一样。
- 给 ACK 用加一的 CSeq 发一次,看 UAS 回什么码。
- 把 tag 换成同一个固定值,并发发两条 INVITE,看服务端怎么处理。
常见误区
- 以为 ACK 的 CSeq 要加一。 ACK 沿用被确认那个 INVITE 的号,加了就是 481, 而 481 看起来像「会话不存在」,容易误判成掉线。
- 以为 tag 可以随便固定。 测试可以、生产不行, 两通并发电话撞同一个 tag 会被服务端当成同一通。
- BYE 之后还想用原来的 Call-ID 继续发。 对话已经作废,必须重新 INVITE 并生成新 Call-ID。
自测三问(点开对答案)
1. 对话标识的三样分别谁生成? —— **Call-ID 由主叫生成、local tag 由自己生成、remote tag 由对方生成。** 主叫侧的 local 等于被叫侧的 remote。 2. 看到 481 应该先查什么? —— **先查 CSeq 与 branch**,而不是查网络。 481 的含义是「你给的标识我这儿没有」,属于标识错而不是链路断。 3. 为什么排障要先按 Call-ID 捞报文? —— **一个 branch 只对应一对请求应答**,从 branch 入手会漏; Call-ID 能捞出整通电话的全部报文,再按 CSeq 排序就对上了。下一节把八条报文完整跑一遍——注册、呼叫、挂断全走通。
- ☐ (本节完,下一节 09.01.05 SIP通信流程及消息)