技术 2019.06.29 38 阅读

为什么Python3从Redis取出的数据都是Bytes?

在使用Python3操作Redis时,很多开发者都会遇到一个“反直觉”的现象:明明存入的是字符串,取出来却变成了bytes类型(如 b'104')。本文将从Redis底层设计、Python3编码模型​和工程实践三个层面,彻底讲清楚这一现象背后的原理,并给出可落地的解决方案。

· · ·

一、问题重现

我们先看一段最常见的代码:

import redis

r = redis.Redis(host='localhost', port=6379)
r.hset('article:1', 'views', 104)

data = r.hgetall('article:1')
print(data)

输出结果往往是:

{b'views': b'104'}

解析时会抛出异常:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0x80 in position 0: invalid start byte

存入的明明是普通的字符串,取出来的却是 {b'key': b'value'}——每个键和值前面都带了一个醒目的 b 前缀,而且中文字符直接变成了一串不可读的十六进制字节码。这究竟是怎么回事?

简单来说:Redis 在协议层面只认识字节(bytes),Python 的 redis-py 客户端默认保持了这个原始形态,不会帮你自动解码。

一、根源:Redis 协议(RESP)本身就是二进制安全的

要理解这个现象,必须先明白 Redis 在“说”一门什么样的语言。

Redis 客户端与服务器之间的通信遵循一套名为 RESP(Redis Serialization Protocol,Redis 序列化协议) 的规则。这套协议的一个核心设计原则是二进制安全(binary-safe)——这意味着协议层不关心你传输的内容是文字、图片还是序列化后的对象,它只管按字节传输。

可以简单类比一下:RESP 协议就像一台复印机,你放一张写满英文的纸进去,它复印出来的还是那些墨水痕迹;你放一张中文报纸进去,它复印出来的还是墨水痕迹。复印机本身并不需要“看懂”纸上的内容是什么。

Redis 官方文档对此有明确说明:RESP 协议使用前缀长度来传输大块数据,因此它不需要处理从客户端到服务器的任何数据格式转换工作。这种设计也让 Redis 变得异常灵活——无论你是存一张图片的二进制数据,还是一个 Python 对象的 pickle 序列化结果,Redis 都能照单全收,不加任何语义判断。

二、redis-py 的默认行为:安全第一

有了 Redis 底层只认 bytes 的前提,Python 的 redis-py 客户端在实现时面临一个选择:取出的数据要不要自动解码成字符串?

redis-py 的官方答案是:默认不解码,原样返回 bytes。官方文档明确写道:“All responses are returned as bytes in Python.”。

python
>>> r.set('foo', 'bar')
True
>>> r.get('foo')
b'bar'   # 注意这个 b 前缀

为什么这样设计?原因很直白:Redis 里什么都可以存——可能是字符串、可能是图片的二进制数据、可能是 Protocol Buffers 序列化后的字节流、也可能是其他语言写入的任意编码文本。

如果 redis-py 自作主张把所有返回数据都按 UTF-8 解码成字符串,那么当你取出一张图片的二进制数据时,程序会直接因 UnicodeDecodeError 而崩溃。所以 redis-py 的态度是:“我不知道你存的是什么,原样还给你,你自己决定怎么处理。”

这也解释了为什么你在写入时完全没有“编码”的感觉——redis-py 在 set() 时就已经默默帮你把 Python 字符串编码成了 UTF-8 字节,存入 Redis;而取数据时原样返回,不帮你做反向操作。

一个常见的误区是:看到 b'bar' 以为 Redis “弄坏了”我的数据。实际上 Redis 什么都没做——它只是忠实地返回了它当初收到的字节。而 redis-py 在写入时帮你做的编码,让你产生了“我存进去的就是字符串”的错觉。

三、三种解决方案

弄清楚原因之后,解决方案自然就清晰了。一共有三种思路,按推荐程度从高到低排列。

方案一:decode_responses=True(最推荐)

在创建 Redis 连接时,StrictRedis类构造函数有个参数decode_responses,加上 decode_responses=True 参数,告诉 redis-py 把取出的所有字符串响应自动解码:

python
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
r.set('name', '张三')
print(r.get('name'))  # 张三(str,没有 b 前缀)

这是一劳永逸的方案。一旦在客户端层面开启自动解码,所有返回值为字符串类型的 Redis 命令都会按指定的编码(默认 UTF-8)自动解码。对于日常开发的绝大多数场景,这是最合适的选择。

如果使用连接池,参数需要传给 ConnectionPool 而非 Redis 实例:

python
pool = redis.ConnectionPool(host='localhost', port=6379, db=0, decode_responses=True)
r = redis.Redis(connection_pool=pool)

方案二:手动解码(适用于特定场景)

如果某些场景下不想全局开启自动解码(例如你要同时存取字符串和图片二进制数据),可以在每次取值时手动解码:

python
result = r.get('name')
if result is not None:
    result = result.decode('utf-8')

对于 Hash 等结构需要同时对键和值分别解码:

python
stored = r.hgetall('user:1001')
decoded = {k.decode('utf-8'): v.decode('utf-8') for k, v in stored.items()}

官方建议:如果某个客户端实例需要同时存取字符串和二进制数据,可以考虑使用两个独立的客户端实例——一个开启 decode_responses=True 专用于字符串,另一个保持默认用于二进制数据。

方案三:单字符 ASCII 内容的特殊情况

前面提到,b'bar' 返回时字符本身是可读的,这是因为 b'bar' 中的字符 b、a、r 均在 ASCII 码表范围内。在 Python 中,当一个 bytes 对象的内容全部是 ASCII 字符(0-127)时,它会直接显示字符而不是十六进制表示。这并非“没有乱码”,而是 ASCII 和 UTF-8 在这些字符上的编码完全一致。

四、留意编码一致性

当数据包含中文等非 ASCII 字符时,开启 decode_responses=True 虽然能直接看到文字,但有一个重要前提:写入时的编码和读取时的解码必须一致。

redis-py 默认使用 UTF-8 编码。如果你的数据是以其他编码(如 GBK)写入 Redis 的,那么即使开启 decode_responses=True,用 UTF-8 去解码 GBK 编码的字节一样会产生乱码或报错。

因此最佳实践是:全链路统一使用 UTF-8 编码,包括应用层字符串、Redis 客户端配置、以及任何跨语言读写的对接方。这一原则不仅适用于 Redis,也适用于任何涉及字符编码的数据存储场景。

实际上,Python 对 bytes 类型的数据在交互式输出时,默认使用带 b 前缀的单引号或双引号来表示,例如 b'bar' 或 b"bar",这是其 repr() 方法的默认表现。因此,如果你想要在代码中明确写出一个 bytes 字面量,就需要采用这种带 b 前缀的引号形式。

写在最后

{b'view': b'104'} 这个小小的 b 前缀,背后是一套完整的设计逻辑:Redis 追求极致的通用性和灵活性,选择了二进制安全的协议;redis-py 追求安全性和可预测性,选择了默认不做任何解码。

理解了这一点,下次看到 b'xxx' 就不会再有疑惑。在大多数应用场景中,简单地在连接时加上 decode_responses=True,就能和 b 前缀和平告别。


参考:关于Python2和3中的Unicode的更多信息