赵明寰 https://whoiami.github.io/
Long Story Short
Blob 在做partial update 并且符合small change 记录到undo record ,之后如果读请求走mvcc 读到这个undo record 的时候有概率造成crash。
场景复现
环境8.0.25
client1
create table t (a int primary key, b json);
insert into t values (1, '[ "abc", "def" ]');
update t set b=JSON_SET(b, '$[0]', REPEAT('w', 10000));
client2
set SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
begin;
select * from t where a =1;
client1
UPDATE t SET b = JSON_REMOVE(b, '$[1]') where a = 1;
client2
select * from t where a =1;
Core stack
#3 mach_read_from_1 (b=0x0) at /storage/innobase/include/mach0data.ic:66
#4 mach_read_next_compressed () at /storage/innobase/include/mach0data.ic:288
#5 trx_undo_read_blob_update () at /storage/innobase/trx/trx0rec.cc:985
#6 trx_undo_update_rec_get_update () at /storage/innobase/trx/trx0rec.cc:1882
#7 trx_undo_prev_version_build () at /storage/innobase/trx/trx0rec.cc:2550
#8 row_vers_build_for_consistent_read () at /storage/innobase/row/row0vers.cc:1305
#9 row_sel_build_prev_vers_for_mysql () at /storage/innobase/row/row0sel.cc:3144
#10 row_search_mvcc () at /storage/innobase/row/row0sel.cc:5373
#11 ha_innobase::index_read () at /storage/innobase/handler/ha_innodb.cc:9956
#12 handler::index_read_map () at /sql/handler.h:5071
#13 handler::ha_index_read_map () at /sql/handler.cc:3249
#14 read_const () at /sql/sql_executor.cc:3503
#15 join_read_const_table () at /sql/sql_executor.cc:3373
#16 JOIN::extract_func_dependent_tables () at /sql/sql_optimizer.cc:5515
#17 JOIN::make_join_plan () at /sql/sql_optimizer.cc:5035
#18 JOIN::optimize () at /sql/sql_optimizer.cc:535
#19 Query_block::optimize () at /sql/sql_select.cc:1819
#20 Query_expression::optimize () at /sql/sql_union.cc:678
#21 Sql_cmd_dml::execute_inner () at /sql/sql_select.cc:775
#22 Sql_cmd_dml::execute () at /sql/sql_select.cc:575
#23 mysql_execute_command () at /sql/sql_parse.cc:4412
#24 dispatch_sql_command () at /sql/sql_parse.cc:5000
#25 dispatch_command () at /sql/sql_parse.cc:1841
#26 do_command () at /sql/sql_parse.cc:1320
Root Cause
对于Blob 字段进行partical update,由于是small change ,binary diff 记录到了undo record 里面。select 的时候走row_search_mvcc => trx_undo_read_blob_update 路径,如果读到diff 长度,需要申请而外的内存,lob_undo_data.copy_old_data 将binary diff 从undo record 拷贝到独立内存,但是如果长度为0(lob_diff.m_length 长度是0),申请内存返回了nullptr (注意这里是个坑,后面再说),之后会将undo_ptr 直接置空,后续流程继续用undo_ptr 解析undo record 的时候crash。
static const byte * trx_undo_read_blob_update(...) {
undo_ptr = lob_undo_data.copy_old_data(undo_ptr, lob_diff.m_length);
...
ulint n_entry = mach_read_next_compressed(&undo_ptr);
}
很明显这里的处理流程有问题。修复的方法也比较直接,只要不更新lob_undo_data 的m_old_data 字段就可以了。但是这里需要明确的是为什么会记录undo 长度为0 的binary diff 在undo record 里面?
Keep Digging
这个undo 的binary diff 的长度是从
Value::remove_in_shadow => TABLE::add_binary_diff 添加到binary diff
对应的数据结构是:m_partial_update_info->m_binary_diff_vectors
report undo record 的时候,函数 trx_undo_report_blob_update 调用
update->get_binary_diff_by_field_no()->get_binary_diffs 里面
返回 m_partial_update_info->m_binary_diff_vectors
所以这里需要看一下Value::remove_in_shadow 的逻辑。这个函数主要计算两部分binary diff,一个是Json 的number of elements部分的binary diff,另一个是json meta 部分的binary diff。具体的Json 格式格式解析可以参考https://developer.aliyun.com/article/598070,这里举例说明remove_in_shadow 是如何计算出长度为0 的binary diff 的。
create table t (a int primary key, b json);
insert into t values (1, ‘[ “abc”, “def” ]’);
执行后内存结构如下:
0x02 - type: small JSON array
0x02 - number of elements (low byte)
0x00 - number of elements (high byte)
0x12 - number of bytes (low byte)
0x00 - number of bytes (high byte)
0x0C - type of element 0 (string)
0x0A - offset of element 0 (low byte)
0x00 - offset of element 0 (high byte)
0x0C - type of element 1 (string)
0x0E - offset of element 1 (low byte)
0x00 - offset of element 1 (high byte)
0x03 - length of element 0
'a'
'b' - content of element 0
'c'
0x03 - length of element 1
'd'
'e' - content of element 1
'f'
UPDATE t SET b = JSON_REMOVE(b, ‘$[1]’) where a = 1;
执行后内存结构如下:
0x02 - type: small JSON array
CHANGED 0x01 - number of elements (low byte)
CHANGED 0x00 - number of elements (high byte) // 高位虽然没有变
0x12 - number of bytes (low byte)
0x00 - number of bytes (high byte)
0x0C - type of element 0 (string)
0x0A - offset of element 0 (low byte)
0x00 - offset of element 0 (high byte)
[Free] 0x0C - type of element 1 (string)
[Free] 0x0E - offset of element 1 (low byte)
[Free] 0x00 - offset of element 1 (high byte)
0x03 - length of element 0
'a'
'b' - content of element 0
'c'
0x03 - length of element 1
'd'
'e' - content of element 1
'f'
原本的逻辑是JSON_REMOVE需要生成两个binary diff,一个是修改number of elements的信息产生的binary diff,一个是meta 拷贝产生的binary diff。这里,第一个binary diff 修改了number of elements, 对应的是上面标注CHNAGED 的部分。第二个binary diff是meta 拷贝产生的diff,正常逻辑需要保证meta 信息的连续,所以需要在meta 信息被删除,有空洞的时候把下面的meta 信息拷贝上来,但是,由于这里是删除最后一个element,没有meta空洞,不需要meta数据拷贝,所以这里Value::remove_in_shadow 里面计算出来的数据binary diff 是0。也就是对应的标注FREE 的地方,这里的free meta内存可以给之后的新element使用,所以是不需要移动的。
Follow Up
尝试查看最新官方代码8.0.33。实测还是可能binary diff 计算出等于0的情况的。但是其他逻辑并没有做任何特殊处理,但是在后续mvcc 读的时候没有出现crash。
查看对应的undo_data_t::copy_old_data 代码发现:
const byte *undo_data_t::copy_old_data(const byte *undo_ptr, ulint len) {
m_old_data =
ut::new_arr_withkey(UT_NEW_THIS_FILE_PSI_KEY, ut::Count{m_length});
if (m_old_data == nullptr) {
return nullptr;
}
...
}
8.0.25 当中这里len = 0 的时候,申请内存的操作返回是nullptr,后续处理流程出错导致crash。
8.0.33 这里返回不是nullptr,而是一个正常的指针。
进一步探究发现,8.0.33官方申请array 内存的逻辑有改动。正常逻辑如果申请array 类型的内存会事先申请一个array meta 内存,当中存放array 的长度。8.0.33的逻辑是如果申请长度为0,会返回array meta 的地址(非空)。
由于8.0.33跟8.0.25 在处理申请array 内存的逻辑上,对于返回值是否是nullptr 的处理上有区别,8.0.33直接用不返回nullptr 的方式避过了文章开头的crash。所以8.0.33暂时没有这个问题。但是对于8.0.33来说这是一个隐患,因为之后如果官方修改array 类型申请内存的方式,对于申请0长度的array 如果返回nullptr ,这里依然会crash。
这里给官方提交了BUG REPORT ,但是官方认为“can‘t repeat crash” 认为没有问题。
8.0.33代码返回非空代码路径:
trx_undo_read_blob_update
=>undo_data_t::copy_old_data
=>ut::new_arr_withkey(UT_NEW_THIS_FILE_PSI_KEY, ut::Count{m_length});
=>Alloc_pfs::alloc(std::size_t size,pfs_metadata::pfs_memory_key_t key);
static inline void *alloc(std::size_t size,pfs_metadata::pfs_memory_key_t key){
const auto total_len = size + Alloc_pfs::metadata_len;
auto mem = Alloc_fn::alloc(total_len);
return static_cast(mem) + Alloc_pfs::metadata_len;
}
8.0.33 代码这里计算申请字节长度的时候,size 是上层传入的申请的大小(这里是0),函数内部加上了Alloc_pfs::metadata_len长度,这个是array 的meta 部分长度,导致了总申请长度不是0,最终返回的指针也就不会是nullptr。
这里附上8.0.25 返回nullptr 的代码路径:
trx_undo_read_blob_update
=>undo_data_t::copy_old_data
=>UT_NEW_ARRAY_NOKEY(byte, m_length)
=>ut_allocator(key).new_array(n_elements, UT_NEW_THIS_FILE_PSI_KEY)
=>new_array(size_type n_elements, PSI_memory_key key)
=> allocate(...)
pointer allocate(size_type n_elements, ...) {
if (n_elements == 0) {
return (nullptr);
}
}
8.0.25 可以复现(size 是0 就返回nullptr)。8.0.26 没问题(做了类似8.0.33 的处理返回array meta)。 8.0.33(目前最新版本) 接口做了重做,返回array meta。
Reported bug
https://bugs.mysql.com/bug.php?id=111647
Reference
https://dev.mysql.com/blog-archive/mysql-8-0-optimizing-small-partial-update-of-lob-in-innodb/
https://whoiami.github.io/INNODB_BLOB
https://developer.aliyun.com/article/598070
https://github.com/mysql/mysql-server/tree/mysql-8.0.33
https://github.com/mysql/mysql-server/tree/mysql-8.0.25