字符编码知识 乱码原因

参考

字符编码基础知识其一

字符编码笔记:ASCII,Unicode 和 UTF-8 ——阮一峰

分享一下我所了解的字符编码知识

字符编码详解及由来(UNICODE,UTF-8,GBK)

字符编码详解(基础) ——PHP鸟哥

文件和字符编码

编码方式之ASCII、ANSI、Unicode概述

字节(Byte或byte):计算机系统中用于计量存储容量的一种计量单位, 1B=8bit

字符(Character)是各种文字和符号的总称,包括各国家文字、标点符号、图形符号、数字等,但是一个字符在计算机中占用多少字节是与编码方式有关的,不同的编码方式占用的内存不一样。例如:标点符号+是一个字符,汉字我们是两个字符,在GBK编码中一个汉字占2个字节,在UTF-8编码中一个汉字占3个字节。

mysql(utf8mb4编码)中varchar(255) 255是字符长度不需要考虑不同字符(中文,英文)字节占用问题这是mysql底层处理的

字节字符有什么联系和区别呢?简单来说字节是计算机存储和操作的最小单位,字符是人们阅读的最小单位;字节是存储(物理)概念,字符是逻辑概念;字节代表数据(内涵和本质),字符代表其含义字符由字节组成
举几个例子说明两者区别:“中国”包含2个字符,GBK编码表示需要4个字节,UTF-8编码需要6个字节;数字“1234567890”,包含10个字符,用int32类型表示只需4个字节

编码规范 随着计算机的普及,人们希望能在计算机中显示字符,但是计算机只能显示0和1这样的二进制数,为了显示字符,国际组织就制定了编码规范,希望使用不同的二进制数来表示代表不同的字符,这样电脑就可以根据二进制数来显示其对应的字符。所谓字符集其实就是一套编码规范中的子概念,所以我们通常就称呼其为XX编码,XX字符集。例如:GBK 编码规范,根据这套编码规范,计算机就可以在中文字符和二进制数之间相互转换。而使用GBK编码就可以使计算机显示中文字符。

字库表 一套编码规范不一定包含世界上所有的字符,每套编码规范都有自己的使用场景,而字库表就存储了某种编码规范中能显示的所有字符,计算机就是根据二进制数字库表中找到与之对应的字符然后显示给用户的字库表相当于一个存储字符的数据库。例如:几乎所有汉字都保存在GBK 编码规范的字库表中。所以可以显示汉字,但法语,俄语并不在其字库表中,所以使用GBK编码的文档不能正常显示法语,俄语等不包含在其字库表中的字符。

编码字符集(字符集)在一个字库表中,每一个字符都有一个对应的二进制地址,而编码字符集就是这些地址的集合。字符集定义了字符和二进制的对应关系,为每个字符分配了唯一的编号。可以将字符集理解成一个很大的表格,它列出了所有字符和二进制的对应关系,计算机显示文字或者存储文字,就是一个查表的过程

字符编码(编码方式 )而字符编码规定了如何将字符的编号存储到计算机中,如果使用了类似 GB2312 和 GBK 的变长存储方案(不同的字符占用的字节数不一样),那么为了区分一个字符到底使用了几个字节,就不能将字符的编号直接存储到计算机中,字符编号在存储之前必须要经过转换,在读取时还要再逆向转换一次,这套转换方案就叫做字符编码。

字符集和字符编码的关系

通常特定的字符集采用特定的编码方式(即一种字符集对应一种字符编码(例如:ASCII、IOS-8859-1、GB2312、GBK,都是即表示了字符集又表示了对应的字符编码,但Unicode不是,它采用现代的模型)),因此基本上可以将两者视为同义词
字符和字符编码的异同可参见:https://www.cnblogs.com/lanhaicode/p/11214827.html

粗略总结

  • 解码过程:一个较短的二进制数,通过一种编码方式,转换成编码字符集中正常的地址,然后在字库表中找到一个对应的字符,最终显示给用户。  
  • 编码过程:字库表中的一个文字或符号,在字符集中找到对应的二进制串,然后通过一种编码方式,存储到计算机存储设备中

常见的编码规范及其发展过程

单字节

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码),是最早产生的编码规范,共128个字符,用7位二进制表示(00000000-01111111即0x00-0x7F),可以看出ASCII码只需要1个字节的存储空间,它没有特定的编码方式,直接使用地址对应的二进制数来表示,非要说那就叫他ASCII 编码方式。可以表示阿拉伯数字和大小写英文字母,以及一些简单的符号。

EASCII(Extended ASCII),256个字符,用8位二进制表示(00000000-11111111即0x00-0xFF)。当计算机传到了欧洲,国际标准化组织在ASCII的基础上进行了扩展,形成了ISO-8859标准,跟EASCII类似,兼容ASCII,在高128个码位上有所区别。ISO-8859-1编码范围使用了单字节内的所有空间,在支持ISO-8859-1的系统中传输和存储其他任何编码的字节流都不会被抛弃。换言之,把其他任何编码的字节流当作ISO-8859-1编码看待都没有问题。这是个很重要的特性

MySQL数据库默认编码是Latin1就是利用了这个特性。ASCII编码是一个7位的容器,ISO-8859-1编码是一个8位的容器。由此可见,ISO-8859-1只占1个字节,且MySQL数据库默认编码就是ISO-8859-1,有时,tomcat服务器默认也是使用ISO-8859-1编码,然而ISO-8859-1是不支持中文的,有时这就是在浏览器上显示乱码的原因。但是由于欧洲的语言环境十分复杂,所以根据各地区的语言又形成了很多子标准,ISO-8859-1、ISO-8859-2、ISO-8859-3、……、ISO-8859-16。 

双字节  

当计算机传到了亚洲,256个码位就不够用了。于是乎继续扩大二维表,单字节改双字节,16位二进制数,65536个码位。在不同国家和地区又出现了很多编码,中国的GB2312港台的BIG5、日本的Shift JIS,韩国的Euc-kr等等。


GBK全称《汉字内码扩展规范》,支持国际标准ISO/IEC10646-1和国家标准GB13000-1中的全部中日韩汉字。GBK字符集中所有字符占2个字节,不论中文英文都是2个字节。 没有特殊的编码方式,习惯称呼GBK 编码。

一般在国内,汉字较多时使用。GBK(Chinese Internal Code Specification)是GB2312的扩展,GBK 向下与 GB 2312 编码兼容,向上支持 ISO 10646.1国际标准,是前者向后者过渡过程中的一个承上启下的产物。ISO 10646 是国际化标准组织 ISO 公布的一个编码标准,即 Universal Multilpe-Octet Coded Character Set(简称UCS),与 Unicode 组织的 Unicode 编码完全兼容。
GBK编码,是在GB2312-80标准基础上的内码扩展规范,使用了双字节编码方案,其编码范围从8140至FEFE(剔除xx7F),共23940个码位,共收录了21003个汉字,完全兼容GB2312-80标准,支持国际标准ISO/IEC10646-1和国家标准GB13000-1中的全部中日韩汉字,并包含了BIG5编码中的所有汉字。 

多字节

当互联网席卷了全球,地域限制被打破了,不同国家和地区的计算机在交换数据的过程中,由于之前出现的各种不同的编码方式,文本就会出现乱码的问题,即对同一组二进制数据,不同的编码会解析出不同的字符。而当某个字符集中没有文本中的字符编码时,就会出现乱码。

通用字符集UCS(Universal Character Set)对应两种编码:对每一个字符采用四个8比特字节编码的称为UCS-4,对每一个字符采用两个8比特字节编码的称为UCS-2。

Unicode字符集的出现就是为了解决这个问题。Unicode 是一个很大的集合,现在的规模可以容纳100多万个符号。每个符号的编码都不一样,比如,U+0639表示阿拉伯字母AinU+0041表示英语的大写字母AU+4E25表示汉字。具体的符号对应表,可以查询unicode.org,或者专门的汉字对应表

需要注意的是,Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。

比如,汉字的 Unicode 是十六进制数4E25,转换成二进制数足足有15位(100111000100101),也就是说,这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要3个字节或者4个字节,甚至更多。

这里就有两个严重的问题,第一个问题是,如何才能区别 Unicode 和 ASCII ?计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?第二个问题是,我们已经知道,英文字母只用一个字节表示就够了,如果 Unicode 统一规定,每个符号用三个或四个字节表示,那么每个英文字母前都必然有二到三个字节是0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这是无法接受的。

它们造成的结果是:1)出现了 Unicode 的多种存储方式,也就是说有许多种不同的二进制格式,可以用来表示 Unicode。2)Unicode 在很长一段时间内无法推广

互联网的普及,强烈要求出现一种统一的编码方式。UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式。其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示),不过在互联网上基本不用。重复一遍,这里的关系是,UTF-8 是 Unicode 的实现方式之一。

BOM

BOM(字节顺序标记, byte-order mark)也是我们常见到的名词, 比如我们的代码文件都要求使用UTF-8无BOM形式保存, 不然有可能编译不过, 或者出现一些诡异的事情.
BOM实际上是位于码位U+FEFF的Unicode字符的名称.
对于UTF-16, UCS-2, UTF-32 / UCS-4这类码元不是8位的编码方式来说, 编码后的数据要存储/传输时, 必然会有字节序的问题, BOM出现在字节流的开头, 则用于标识该字节流的字节序. 各编码方案按自己的方式对U+FEFF进行编码, 放在头部即可标志编码该字节流时使用的字节序.
比如, 当我们知道即将读取的字节流以UTF-16编码, 字节序未知, 读到的前两个字节是0xFF, 0xFE, Unicode中U+FFFE则不映射到字符, 而这两个字节必定是编码的U+FEFF, 因此可以判断当前字节流使用小端序, 即UTF-16 LE

对于UTF-8, 由于它使用的是8位的码元, 不存在字节序的问题, 也不建议在头部添加BOM, 因为可能影响到一些工具, 因此使用无BOM的UTF-8成了主流.

ANSI

ANSI全称(American National Standard Institite)美国国家标准学会(美国的一个非营利组织),首先ANSI不是指的一种特定的编码,而是不同地区扩展编码方式的统称,各个国家和地区所独立制定的兼容ASCII,但互相不兼容的字符编码,微软统称为ANSI编码

总结对照

常用的字符编码ASCII,GBK,GB2312,BIG5,UTF-8 英文和中文简繁

Unicode字符集的编码方式有UTF-8,UTF-16,UTF-32

中文乱码产生的原因

PHP获取中文的第一个字符

//多字节字符串 PHP文件编码为UTF-8
$str = '你好PHP';
var_dump($str[0]); //输出结果 b"ä",乱码
var_dump(substr($str, 0, 1));//输出结果 b"ä",乱码
var_dump(substr($str, 0, 3));//输出结果 你 ,utf-8编码中一个汉字是三个字节
var_dump(mb_substr($str, 0, 1));//输出结果 你

PHP处理字符串的方式默认是把字符串作为单字节字符处理的,例如数组方式取字符和普通字符串函数

PHP处理多字节字符串需要用这些扩展 国际化与字符编码支持 常用的有mbstring 扩展和 iconv 函数

mbstring扩展支持的编码 https://www.php.net/manual/zh/mbstring.supported-encodings.php

iconv_get_encoding 获取 iconv 扩展的内部配置变量,

文本文件和二进制的区别

文本文件是二进制文件的一种,底层存储也是0和1;文本文件可读性和移植性好,但表现字符有限;二进制文件数据存储紧凑,无字符编码限制。文本文件基本上只能存放数字、文字、标点等有限字符组成的内容;二进制没有字符约束,可随意存储图像、音视频等数据。

用存储数字的例子可以形象的看出文本文件和二进制文件存储内容上的差异。例如要存储数字1234567890,文本文件要存储0-9这十个数字的ASCII码,对应的十六进制表示为:31 32 33 34 35 36 37 38 39 30,占用10个字节;1234567890对应的二进制为“‭0100 1001 1001 0110 0000 0010 1101 0010‬”,占用4个字节(二进制表示32位,一个字节8位),存储到文件的16进制表示为(大端):49 96 02 D2。

文本文件按字符存放内容,二进制按字节存放,这是两种文件最本质的区别。根据这个特性,可以推断出一些常见结论:二进制文件常常比文本文件紧凑,占用空间少;文本文件更友好易用,能用所见即所得的方式编辑;二进制文件常常需要专用程序打开,等等。

回过头看文本编辑器打开二进制文件常常是乱码的现象。例如一个二进制文件存放了一个整数1234(四个字节),用16进制表示为:00 00 04 D2。文本编辑器打开后逐个字符解释,会发现这几个字节拼不出可显示的字符,只好乱码相待。乱码的原因是文本编辑器不能正确解析字节流,这也是二进制文件需要用专用软件打开的原因。例如jpg文件要用看图软件打开,如果用音乐播放器打开,完蛋!视频文件要用播放器打开,用压缩软件打开,歇菜!有的专用软件会做处理打开不支持的格式后不做反应,有的会报错。

文件格式

Windows按文件拓展名识别文件格式,并调用对应的程序打开文件;

(类)Unix系统,拓展名可有可无,有file命令,这个命令可以告诉我们文件到底是什么格式文件拓展名不是文件格式的本质区别,内容才是。把a.zip改成a.txt/a.jgp/a.mp3,无论什么文件名,file都让其原形毕露:Zip archive data, at least v1.0 to extract。file命令的工作原理可这篇文章

PHP读取txt文本文件获取第一个字符乱码

参考

PHP检测文件BOM头

ANSCII编码对照表

有一个ASCII的编码的文本文件,在linux上打开会显示乱码,因此使用windows记事本打开转成了UTF-8编码,然后用php读取该文本文件第一个字符时出现乱码,原因是windows记事本保存文件时会给文本文件增加bom头。

php检测处理bom头的原理,就是用ord函数检测前三个字符在ASCII编码中的数字是否为239,187,191

if (ord($contents[0]) === 239 && ord($contents[1]) === 187 && ord($contents[2]) == 191)
{
   $contents = substr($contents, 3);
   var_dump($contents[0]);
}

对应的字符如下

用户的操作系统类型是不确定,因此文本文件的字符编码也无法确定 ,需要对用户上传的文本文件转换字符编码

$fileContents = file_get_contents($path);//读取文件字符串,此处可以用框架方法替代
$encoding = mb_detect_encoding($fileContent, ['ASCII', 'GBK', 'GB2312', 'BIG5', 'UTF-8']);//获取文件内容编码
$fileContent = mb_convert_encoding($fileContent, 'UTF-8', $encoding);//转码
//$contents = iconv($encoding, 'UTF-8', $contents);//iconv也可以

无限极分类的多种实现方式

递归

从数据库中查询出所有数据,然后递归处理

$category = [[
    'id' => 1,
    'pid' => 0,
    'name' => '1级分类',
], [
    'id' => 2,
    'pid' => 1,
    'name' => '2级分类',
], [
    'id' => 3,
    'pid' => 2,
    'name' => '3级分类',
], [
    'id' => 4,
    'pid' => 3,
    'name' => '4级分类',
], [
    'id' => 5,
    'pid' => 4,
    'name' => '5级分类'
], [
    'id' => 6,
    'pid' => 6,
    'name' => '6级分类'
]];

//递归成树形
function recurSionToTree(array $array = [], $pid = 0)
{
    $result = [];

    foreach ($array as $key => $value) {

        if ($value['pid'] == $pid) {
            $children = recurSionToTree($array, $value['id']);

            if ($children) {
                $value['children'] = $children;
            }

            $result[] = $value;
        }
    }

    return $result;
}

引用

从数据库查询出所有数据,然后遍历

function refToTree($data)
{
    $items = array();

    foreach ($data as $v) {
        $items[$v['id']] = $v;
    }

    $tree = array();

    foreach ($items as $k => $item) {
        if (isset($items[$item['pid']])) {
            $items[$item['pid']]['children'][] = &$items[$k];
        } else {
            $tree[] = &$items[$k];
        }
    }

    return $tree;
}

冗余字段,空间换时间

参考连接:https://learnku.com/courses/ecommerce-advance/7.x/database-structure/9086

在开始之前,我们需要先整理好 categories 表的字段名称和类型:

字段名称描述类型加索引缘由
id自增长 IDunsigned big int主键
name类目名称varchar
parent_id父类目 IDunsigned big int, null外键
is_directory是否拥有子类目tinyint
level当前类目层级unsigned int
path该类目所有父类目 idvarchar

这里我们需要解释一下 path 字段的意义,在无限级分类的实现中我们经常会遇到以下几个场景:

  • 场景一:查询一个类目的所有祖先类目,需要递归地去逐级查询父类目,会产生较多的 SQL 查询,从而影响性能。
  • 场景二:查询一个类目的所有后代类目,同样需要递归地逐级查询子类目,同样会产生很多 SQL 查询。
  • 场景三:判断两个类目是否有祖孙关系,需要从层级低的类目逐级往上查,性能低下。

而 path 字段就是用于解决这些问题而存在的,path 字段会保存该类目所有祖先类目的 ID,并用字符 - 将这些 ID 分隔开,根类目的 path 字段为 -。比如如下类目:

[
    [
        "id" => 1,
        "name" => "手机配件",
        "parent_id" => null,
        "level" => 0,
        "path" => "-"
    ],
    [
        "id" => 2,
        "name" => "耳机",
        "parent_id" => 1,
        "level" => 1,
        "path" => "-1-"
    ],
    [
        "id" => 3,
        "name" => "蓝牙耳机",
        "parent_id" => 2,
        "level" => 2,
        "path" => "-1-2-"
    ],
    [
        "id" => 4,
        "name" => "移动电源",
        "parent_id" => 1,
        "level" => 1,
        "path" => "-1-"
    ],
];

对应的类目树如下:

手机配件(1)
 ├─ 耳机(2)
 │   └─ 蓝牙耳机(3)
 └─ 移动电源(4)

现在我们再来逐一分析刚刚的几个场景:

场景一,查询『蓝牙耳机』的所有祖先类目:取出 path 字段的值 -1-2-,以 - 为分隔符分割字符串并过滤掉空值,得到数组 [1, 2] ,然后使用 Category::whereIn('id', [1, 2])->orderBy('level')->get() 即可获得排好序的所有父类目。

场景二,查询『手机配件』的所有后代类目:取出自己的 path 值 -,然后追加上自己的 ID 字段得到 -1-,然后使用 Category::where('path', 'like', '-1-%')->get() 即可获得所有后代类目。

场景三,判断『移动电源』与『蓝牙耳机』是否有祖孙关系:取出两者中 level 值较大的类目『蓝牙耳机』的 path 值 -1-2- 并赋值给变量 $highLevelPath,取另外一个类目的 path 值并追加该类目的 ID 得 -1-4- 并赋值给变量 $lowLevelPath,然后只需要判断变量 $highLevelPath 是否以 $lowLevelPath 开头,如果是则有祖孙关系。

可以看到我们通过新增一个冗余的 path 字段,就能很好地解决性能问题,这是一种很典型的(存储)空间换(执行)时间策略。

预排序树

参考连接

https://www.jianshu.com/p/48f6db8ea524

https://www.cnblogs.com/sonicit/archive/2013/05/21/3090518.html

记录Let’s encrypt 证书导致的两个严重问题

问题一

起因,公司的某项目APP客户反馈,ios打开数据响应很慢

2020年4月份出现,在ios系统上,证书解析非常慢,导致ios调取接口经常超时,而且时很多运营商都出现过这个问题,时快时慢.

起初以为时服务器网络问题,在PC上通过mtr检测后发现服务器和网络正常

使用ios网络诊断工具Best Net Tools 检测发现服务器和网络正常

最终通过人工对比测试发现,国内使用Let’s encrypt证书的网站,和其它证书赛克铁门,发现Let’s encrypt证书在ios系统上响应很慢

问题二

起因,公司某项目调取第三方同步数据接口的队列报错

GuzzleHttp\Exception\RequestException: cURL error 60: SSL certificate problem: unable to get local issuer certificate (see http://curl.haxx.se/libcurl/c/libcurl-errors.html)

使用postman开启ssl验证接口没有响应,

参考https://stackoverflow.com/questions/46080133/laravel-curl-error-60-ssl-certificate-unable-to-get-local-issuer-certificate 修改php.ini curl.cainof 和openssl.cafile 配置新的证书后,还是报错,和原先配置的证书是一样的,所以并不是这个证书的原因.

最后解决方法,使用guzzle的配置项 verify 设置为false不验证ssl

文档 https://guzzle-cn.readthedocs.io/zh_CN/latest/request-options.html#verify

总结

解决问题后,需要问为什么会出现这些问题,重新复习一下ssl相关知识和原理,后续慢慢完善

是什么?简介

干什么?用途

怎么干?应用

为什么?工作原理

微信图片压缩算法

微信是一个很好的参照物,被大家广为使用并接受。这个扩展就是通过发送微信朋友圈和聊天会话发送了大量图片,对比原图与微信压缩后的图片逆向推算出来的压缩算法。

TIPS:

  • 符号表示无穷大
  • [ 1,3 ) 这是一个区间,表示从1到3之间的所有实数,左边中括号表示闭区间,也就是把1算在区间内。右边小括号表示不包括3。

算法 Luban

参考:https://zhuanlan.zhihu.com/p/56595874

1.判断图片比例值,是否处于以下区间

  • [1,0.5625)即图片处于[1:1 ~ 9:16] 比例范围内
  • [0.5625,0.5)即图片处于[9:16 ~ 1:2]比例范围内
  • [0.5,0) 即图片处于[1:2 ~ 1:∞]比例范围内

2.判断图片最长边是否过边界值

  • [1,0.5625)边界值为:1664 * n (n = 1) ,4990 * n (n=2), 1280*pow(2,n-1)(n>=3)
  • [0.5625,0.5)边界值为:1280 × pow (2,n-1) (n>=1)
  • [0.5,0)边界为:1289*pow(2,n-1)(n>=1)

3.计算压缩图片实际边长值,以第2步计算结果为准,超过某个边界值则:width/pow(2,n-1),height/pow(2,n-1)

4.计算压缩图片的实际文件大小,以第2,3步结果为准,图片比例越大则文件越大

size=(newW*newH)/(width*height)*m

  • [1,0.5625)则width& height 对应1556,4990,1280 *n (n>=3),m对应150,300,300;
  • [0.5625,0.5) 则width=1440,height = 2560,m=200;
  • [0.5,0)则width = 1280,height = 1280/scale,m=500;注:scale为比例值

5.判断第四部的size是否过小

  • [1,0.5625) 则最小size对应60,60,100
  • [0.5626,0.5)则最小size都为100,
  • [0.5,0)则最小size都为100

6.将前面求到的值压缩图片width,height,size 传入压缩流程,压缩图片直到满足以上数值。

算法2

参考:https://blog.csdn.net/a429778435/article/details/80604470

图片尺寸
宽高均 <= 1280,图片尺寸大小保持不变
宽或高 > 1280 && 宽高比 <= 2,取较大值等于1280,较小值等比例压缩
宽或高 > 1280 && 宽高比 > 2 && 宽或高 < 1280,图片尺寸大小保持不变
宽高均 > 1280 && 宽高比 > 2,取较小值等于1280,较大值等比例压缩
注:当宽和高均小于1280,并且宽高比大于2时,微信聊天会话和微信朋友圈的处理不一样。
朋友圈:取较小值等于1280,较大值等比例压缩
聊天会话:取较小值等于800,较大值等比例压缩

图片质量

经过大量的测试,微信的图片压缩质量值 ≈ 0.5

ORM扣库存导致的BUG总结

前一段时间,以前一个项目的积分兑换功能出现了库存超扣现象,排查问题时,一看更新时没有加锁,于是以为是锁的问题,加锁之后没有仔细推敲就汇报bug修复完成.

//初始代码
DB::transaction(function () use ($request, $user_model) {
 $goods_model = Goods::find($request->id);
 if ($goods_model->stock <= 0 || $goods_model->stock < $request->number) {
          throw new \Exception('库存不足');
         }
  //扣库存
 $goods_model->stock-=$request->number;
 $goods_model->save();
 ...
});

//第一次修复BUG,增加悲观锁 for update
DB::transaction(function () use ($request, $user_model) {
 $goods_model = Goods::lockForUpdate()->find($request->id);
 if ($goods_model->stock <= 0 || $goods_model->stock < $request->number) {
          throw new \Exception('库存不足');
         }
  //扣库存
 $goods_model->stock-=$request->number;
 $goods_model->save();
 ...
});

昨天接的报告,库存超扣BUG又出现了,立即查看Binlog 日志排查sql执行记录

看日志,同一时刻,有两个线程(386609669,370064926)更新库存,线程386609669 先将库存更新为2 ,线程370064926随后又将库存更新为9 ,并且响应时间760ms ,猜想是数据库响应问题,正考虑其它解决方案,队列或限流等.

看日志nginx,发现在抢购兑换时间内只有几十的并发,看了一下RDS的资源统计使用率不高,感觉悲观锁可以应付的来,不是性能问题.

继续看binlog 日志,发现库存被 线程370064926更新为9之后,又被其它线程386850322更新为3,继续往后看发现同一时间内有好多线程在同时更新库存而且更新库存数量不相同.

百思不得其解,网上Google Mysql高并发扣库存方案,其中有一只乐观锁方案,提到在事物中更新库存时,要比对程序中的库存数量和mysql中的库存数量,突然间恍然大悟,仔细一看代码.果然,犯了一个相当愚蠢的错误.

DB::transaction(function () use ($request, $user_model) {
 $goods_model = Goods::lockForUpdate()->find($request->id);
 if ($goods_model->stock <= 0 || $goods_model->stock < $request->number) {
          throw new \Exception('库存不足');
         }
  //扣库存
 $goods_model->stock-=$request->number; 标注1
 $goods_model->save();
 
 ...
});

问题分析

标注1

更新使用的是程序模型中的库存数量 – 兑换数量

假设stock为 9 number 为 2
上述写法生成的sql 为 update goods set stock = 7 where id = ?
在高并发场景下这么写显然是非常错误的. 假设商品的初始库存为10,ABC三个用户同一时刻兑换商品,ABC读取到程序模型中的库存数量 $goods_model->stock 都是10 ,由于用了悲观锁for update(这不是重点,不用悲观锁也会出现这种情况) 更新语句变为阻塞执行,用户A将库存改为9,用户BC同样将库存改为9,最后库存剩余还是9,这显然与预期结果不服,正确的剩余库存应该为7

扣库存应该采用 Update goods set stock = stock -1 where id = ? 这种语句,更新时读取的是当前记录的stock的值.最后修正结果代码如下:

//最后修正
DB::transaction(function () use ($request, $user_model) {
 $goods_model = Goods::lockForUpdate()->find($request->id);
 if ($goods_model->stock <= 0 || $goods_model->stock < $request->number) {
          throw new \Exception('库存不足');
         }
  //扣库存 正确的操作 或者使用原生update
  $goods_model->decrement('stock');
 ...
});

decrement 方法生成的语句为update goods set stock = stock -1 where id = ?

总结

  • 外包项目写多了忽略了程序在高并发场景下可能发生的情况.高并发场景要考虑进程线程之间数据一致性和以及程序和数据库(包含nosql) 的数据一致性问题
  • 遇到问题要反复仔细验证,不要轻易下结论

疑问

使用错误的的写法用JMeter模拟并发兑换 20threads/1s 200threads/1s 都没有出现库存超扣现象,由于生产环境不允许压测,所以在本机测试 .猜测可能本机的配置高于服务器,处理能力高于服务器所有没有复现,以后验证成功回头再补充

本机(8核16G) 服务器(4核8G)而且跑了很多项目

解决方案引申

乐观锁

//乐观锁
    public function caseOne(Request $request)
    {
        $this->validate($request, [
            'number' => 'bail|required|integer|min:1',
            'goods_id' => [
                'bail', 'required', 'integer',
                Rule::exists('goods', 'id')->where(function ($query) use ($request) {
                    $query->where('stock', '>=', $request->number);
                }),
            ]
        ]);
        DB::transaction(function () use ($request) {
            $goods = Goods::find($request->goods_id);
            $result = DB::update('update goods set stock = stock - ? where id = ? and stock >= ?', [$request->number, $request->goods_id, $request->number]);
            if (!$result) {
                return response()->json(['message' => '失败']);
            }
            Orders::create([
                'goods_id' => $goods->id,
                'goods_name' => $goods->name,
                'number' => $request->number,
            ]);
        });

        return response()->json();
    }

悲观锁

//悲观锁
    public function caseTwo(Request $request)
    {
        $this->validate($request, [
            'number' => 'bail|required|integer|min:1',
            'goods_id' => [
                'bail', 'required', 'integer',
                Rule::exists('goods', 'id')->where(function ($query) use ($request) {
                    $query->where('stock', '>=', $request->number);
                }),
            ]
        ]);
        DB::transaction(function () use ($request) {
            $goods = Goods::lockForUpdate()->find($request->goods_id);
            $result = DB::update('update goods set stock = stock - ? where id = ? and stock >= ?', [$request->number, $request->goods_id, $request->number]);
            if (!$result) {
                return response()->json(['message' => '失败']);
            }
            Orders::create([
                'goods_id' => $goods->id,
                'goods_name' => $goods->name,
                'number' => $request->number,
            ]);
        });

        return response()->json();
    }

不用事务

//不用事务
    public function caseThree(Request $request)
    {
        $this->validate($request, [
            'number' => 'bail|required|integer|min:1',
            'goods_id' => [
                'bail', 'required', 'integer',
                Rule::exists('goods', 'id')->where(function ($query) use ($request) {
                    $query->where('stock', '>=', $request->number);
                }),
            ]
        ]);
        $goods = Goods::find($request->goods_id);
        $result = DB::update('update goods set stock = stock - ? where id = ? and stock >= ?', [$request->number, $request->goods_id, $request->number]);
        if (!$result) {
            return response()->json(['message' => '失败']);
        }
        Orders::create([
            'goods_id' => $goods->id,
            'goods_name' => $goods->name,
            'number' => $request->number,
        ]);
    }

redis库存

//redis库存
    public function caseFour(Request $request)
    {
        $this->validate($request, [
            'number' => 'bail|required|integer|min:1',
            'goods_id' => [
                'bail', 'required', 'integer',
                function ($attributes, $value, $fail) use ($request) {
                    $stock = Redis::get('goods_id_' . $value);
                    if (is_null($stock)) {
                        return $fail('商品不存在');
                    }
                    if ($stock < $request->number) {
                        return $fail('库存不足');
                    }
                }
            ]
        ]);
        $goods = Goods::find($request->goods_id);
        $result = DB::update('update goods set stock = stock - ? where id = ? and stock >= ?', [$request->number, $request->goods_id, $request->number]);
        if (!$result) {
            return response()->json(['message' => '失败']);
        }
        Orders::create([
            'goods_id' => $goods->id,
            'goods_name' => $goods->name,
            'number' => $request->number,
        ]);
        Redis::decr('goods_id_' . $request->goods_id);

        return response()->json();
    }

队列

public function caseFive(Request $request)
    {
        $this->validate($request, [
            'number' => 'bail|required|integer|min:1',
            'goods_id' => [
                'bail', 'required', 'integer',
                function ($attributes, $value, $fail) use ($request) {
                    $stock = Redis::get('goods_id_' . $value);
                    if (is_null($stock)) {
                        return $fail('商品不存在');
                    }
                    if ($stock < $request->number) {
                        return $fail('库存不足');
                    }
                }
            ]
        ]);
        $goods = Goods::find($request->goods_id);
        SecKill::dispatch($goods, $request->all());

        return response()->json();
    }

https://github.com/yangliuan/shop-demo 有待完善

曳光弹

曳光弹

曳光弹和常规子弹交错装在弹药带上,发射时会照亮弹道路线,如果曳光弹击中目标,那么常规子弹也会击中目标,比提前费力计算更可取,可以及时获得反馈。

类比软件开发,曳光弹适用于新项目。当你构建从未构建过的东西时,与枪手一样,你也没法在黑暗中击中目标。因为你的用户从未见过这样的系统,他们的需求可能会含糊不清。因为你在使用不熟悉的算法、技术、语言或库,你面对这当量未知的事物。同时因为完成项目需要时间,在很大程度上能够确知,你的工作环境将在你完成之前发生变化。

经典的作法是把系统定死,制作大量的文档,注意列出每项需求、确定所有未知因素。限定环境,预先进行大量计算。这种方式非常低效。因为永远有你考虑不到的情况。

注重实效的作法是使用曳光弹,我们要找到某种东西,让我们快速直观和可重复的从需求出发,满足最终系统的某个方面要求。曳光代码并非用过就扔的代码,你编写它是为了保留它。它只是功能不全,可以逐步完善达到最终目标。

曳光开发与项目永不会结束的理念是一致的:总有改动需要完成,总有功能需要增加。这是一个渐进的过程。曳光弹算一种敏捷开发方法。

曳光开发不一定命中目标达到想要的效果。

参考

《程序员的修炼之道从小工到专家》