2023年4月

〇、前言

相信你在实际工作期间经常遇到或听到这样的说法:

“我现在加一下日志,等会儿你再操作下。”

“只有在程序出问题以后才会知道打一个好的日志有多么重要。”

可见日志的记录是日常开发的必备技能。

记录日志的必要性:

当业务比较复杂时,在关键代码附件添加合适的日志是非常重要的,这样可以出现异常后,有章可循,较快速的在不停服的情况下,定位问题并解决。特别是在项目组中,人员较多,若没有统一的日志记录规范,查找系统问题原因就更加费时费力。

记录日志的三种实现:

  1. 当业务比较简单,性能要求不高,只是单纯的记录程序的运行是否正常。此时就可以参考本文第一种实现,仅一种级别的文本记录。
  2. 当业务复杂较复杂,对性能有一定要求时,可以根据实际情况,参考本文的第二、第三种实现。
  3. 当业务非常复杂,必然运行的效率就要求比较高,如何即让程序稳定高效的运行,又能合理记录程序运行状态成为关键。高效的的日志操作可以参考本文的第三种实现。

一、日志的简单记录

如下,为简单的记录开发人员预输出的文本内容,其内容为自定义,输出的时间格式和固定标识需相同。

此方法的性能当然是最差的,针对同一个日志文件,需要独占访问,当同时出现多个记录需求时,会出现排队的情况,导致系统出现卡顿。当然,可以采用多目标文件的方式来提高性能表现,若业务较复杂,还是推荐使用后两种方式。

日志内容测试结果:

public static string strlock = string.Empty;
static void Main(string[] args)
{
    lock(strlock) // 在同一个日志文件操作范围添加同一个锁,避免多线程操作时因抢占资源而报错
    {
        WriteLogPublic.WriteLogFunStr("Program", "Main", "日志内容1");
        // 实际生成的路径:C:\Logs\Program\Main\202304\log07.log
        // 记录的内容:2023-04-07 11-21-31 --- 日志内容1
    }
}

日志类内容:

public class WriteLogPublic
{
    /// <summary>
    /// 记录日志
    /// </summary>
    /// <param name="projectname">项目名称</param>
    /// <param name="controllername">控制器名称</param>
    /// <param name="strlog">日志内容</param>
    public static void WriteLogFunStr(string projectname, string controllername, string strlog)
    {
        string sFilePath = $"C:\\Logs\\{projectname}\\{controllername}\\{DateTime.Now.ToString("yyyyMM")}"; // 根据项目名称等创建文件夹
        string sFileName = $"log{DateTime.Now.ToString("dd")}.log";
        sFileName = sFilePath + "\\" + sFileName; // 文件的绝对路径
        if (!Directory.Exists(sFilePath)) // 验证路径是否存在
            Directory.CreateDirectory(sFilePath); // 不存在则创建
        FileStream fs;
        StreamWriter sw;
        if (File.Exists(sFileName)) // 验证文件是否存在,有则追加,无则创建
            fs = new FileStream(sFileName, FileMode.Append, FileAccess.Write);
        else
            fs = new FileStream(sFileName, FileMode.Create, FileAccess.Write);
        sw = new StreamWriter(fs);
        sw.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss") + " --- " + strlog);
        sw.Close();
        fs.Close();
    }
}

二、通过开源库 HslCommunication 记录不同级别的日志

此方式记录日志,简单高效,可以实现不同级别日志的输出控制,日志选项的配置可以配置在程序的配置文件中,在程序启动时加载即可。

若想实现实时加载,这只能在每次写日志前初始化日志对象,这样估计就影响程序性能了。

日志内容测试结果:

static void Main(string[] args)
{
    // 先初始化配置 HslCommunicationOper
    HslCommunicationOper.HslComLogCollection("Test.ConsoleApp", "Main", 5, HslCommunication.LogNet.GenerateMode.ByEveryHour);
    // HslCommunicationOper.HslComLog("Test.ConsoleApp", "Main"); // 单文件
    // HslCommunicationOper.HslComLogSize("Test.ConsoleApp", "MainSize", 5); // 增加日志单文件大小配置
    // HslCommunicationOper.HslComLogByDate("Test.ConsoleApp", "MainDate", TimeType.Day); // 按照日期分文件保存
    HslCommunicationOper.SetMessageDegree(MessageDegree.WARN);//日志级别
    
    // 记录日志
    HslCommunicationOper.logNet.WriteDebug("调试信息");
    HslCommunicationOper.logNet.WriteInfo("一般信息"); 
    HslCommunicationOper.logNet.WriteWarn("警告信息");
    HslCommunicationOper.logNet.WriteError("错误信息");
    HslCommunicationOper.logNet.WriteFatal("致命信息");

    HslCommunicationOper.logNet.WriteDebug("KeyWord调试信息", "调试信息");
    HslCommunicationOper.logNet.WriteInfo("KeyWord一般信息", "一般信息");
    HslCommunicationOper.logNet.WriteWarn("KeyWord警告信息", "警告信息");
    HslCommunicationOper.logNet.WriteError("KeyWord错误信息", "错误信息");
    HslCommunicationOper.logNet.WriteFatal("KeyWord致命信息", "致命信息");
    HslCommunicationOper.logNet.WriteException("KeyWord-WriteException", new IndexOutOfRangeException());

    HslCommunicationOper.logNet.WriteDebug("调试信息");
    HslCommunicationOper.logNet.WriteInfo("一般信息");
    HslCommunicationOper.logNet.WriteWarn("警告信息");
    HslCommunicationOper.logNet.WriteError("错误信息");
    HslCommunicationOper.logNet.WriteFatal("致命信息");
}
// 日志输出格式示例:
    [警告] 2023-04-07 18:22:03.565 Thread:[001] 警告信息
    [错误] 2023-04-07 18:22:03.605 Thread:[001] 错误信息
    [致命] 2023-04-07 18:22:03.605 Thread:[001] 致命信息
    [警告] 2023-04-07 18:22:03.605 Thread:[001] KeyWord警告信息 : 警告信息
    [错误] 2023-04-07 18:22:03.605 Thread:[001] KeyWord错误信息 : 错误信息
    [致命] 2023-04-07 18:22:03.605 Thread:[001] KeyWord致命信息 : 致命信息
    [致命] 2023-04-07 18:22:03.676 Thread:[001] KeyWord-WriteException : 错误信息:Index was outside the bounds of the array.
    错误源:
    错误堆栈:
    错误类型:System.IndexOutOfRangeException
    错误方法:
    /=================================================[    Exception    ]================================================/
    [警告] 2023-04-07 18:22:03.676 Thread:[001] 警告信息
    [错误] 2023-04-07 18:22:03.676 Thread:[001] 错误信息
    [致命] 2023-04-07 18:22:03.676 Thread:[001] 致命信息

三个相关日志类:

  • HslCommunicationOper:操作类;
  • LogNetCollection:扩展类(提供日志文件的大小、生成新文件频率的配置);
  • MessageDegree:消息级别枚举。
public static class HslCommunicationOper
{
    public static ILogNet logNet = null;
    /// <summary>
    /// 日志文件根目录
    /// </summary>
    public static string rootpath = "C:\\Log";

    /// <summary>
    /// 单日志文件存储
    /// </summary>
    /// <param name="projectname"></param>
    /// <param name="opername">日志文件名</param>
    public static void HslComLog(string projectname, string opername)
    {
        logNet = new LogNetSingle($"{rootpath}\\{projectname}\\{opername}.txt");
        logNet.SetMessageDegree(HslMessageDegree.DEBUG); // 默认存储最低级别为 DEBUG
    }

    /// <summary>
    /// 限定日志文件大小
    /// </summary>
    /// <param name="projectname"></param>
    /// <param name="opername">日志上级文件夹名</param>
    /// <param name="logfilesize">日志文件大小(单位:M) 1~20,默认 5</param>
    public static void HslComLogSize(string projectname, string opername, int logfilesize = 5)
    {
        if (logfilesize < 1 || logfilesize > 20)
            logfilesize = 5;
        logNet = new LogNetFileSize($"{rootpath}\\{projectname}\\{opername}", logfilesize * 1024 * 1024); // 单位M(5M):5 * 1024 * 1024
        logNet.SetMessageDegree(HslMessageDegree.DEBUG); // 默认存储最低级别为 DEBUG
    }

    /// <summary>
    /// 按照日期存储
    /// </summary>
    /// <param name="projectname"></param>
    /// <param name="opername">日志上级文件夹名</param>
    /// <param name="recodemode">传入枚举类型(TimeType),值范围:Minute、Hour、Day、Month、Season、Year</param>
    public static void HslComLogByDate(string projectname, string opername, GenerateMode generateMode = GenerateMode.ByEveryDay)
    {
        logNet = new LogNetDateTime($"{rootpath}\\{projectname}\\{opername}", generateMode); // 按每天
        logNet.SetMessageDegree(HslMessageDegree.DEBUG); // 默认存储最低级别为 DEBUG
    }

    /// <summary>
    /// 按照文件或日期存储
    /// </summary>
    /// <param name="projectname"></param>
    /// <param name="opername">日志上级文件夹名</param>
    /// <param name="generateMode">传入枚举类型 GenerateMode</param>
    public static void HslComLogCollection(string projectname, string opername, int filesize, GenerateMode generateMode = GenerateMode.ByEveryDay)
    {
        logNet = new LogNetCollection($"{rootpath}\\{projectname}\\{opername}", filesize * 1024 * 1024, generateMode);
        logNet.SetMessageDegree(HslMessageDegree.DEBUG); // 默认存储最低级别为 DEBUG
    }

    /// <summary>
    /// 单独配置日志级别
    /// </summary>
    /// <param name="messageDegree">默认 DEBUG</param>
    public static void SetMessageDegree(MessageDegree messageDegree = MessageDegree.DEBUG)
    {
        switch (messageDegree)
        {
            case MessageDegree.DEBUG:
                logNet.SetMessageDegree(HslMessageDegree.DEBUG); // 所有等级存储
                break;
            case MessageDegree.INFO:
                logNet.SetMessageDegree(HslMessageDegree.INFO); // 除 DEBUG 外,都存储
                break;
            case MessageDegree.WARN:
                logNet.SetMessageDegree(HslMessageDegree.WARN); // 除 DEBUG 和 INFO 外,都存储
                break;
            case MessageDegree.ERROR:
                logNet.SetMessageDegree(HslMessageDegree.ERROR); // 只存储 ERROR 和 FATAL
                break;
            case MessageDegree.FATAL:
                logNet.SetMessageDegree(HslMessageDegree.FATAL); // 只存储 FATAL
                break;
            case MessageDegree.None:
                logNet.SetMessageDegree(HslMessageDegree.None); // 不存储任何等级
                break;
        }
    }
}
public class LogNetCollection : LogPathBase, ILogNet, IDisposable
{
    private int fileMaxSize = 10485760; // 默认 10M
    private int currentFileSize = 0;
    private GenerateMode generateMode = GenerateMode.ByEveryYear;

    public LogNetCollection(string filePath, int fileMaxSize = 10485760, GenerateMode generateMode = GenerateMode.ByEveryDay, int fileQuantity = -1)
    {
        base.filePath = filePath;
        this.fileMaxSize = fileMaxSize;
        this.generateMode = generateMode;
        controlFileQuantity = fileQuantity;
        base.LogSaveMode = LogSaveMode.FileFixedSize;
        if (!string.IsNullOrEmpty(filePath) && !Directory.Exists(filePath))
        {
            Directory.CreateDirectory(filePath);
        }
    }

    protected override string GetFileSaveName()
    {
        if (string.IsNullOrEmpty(filePath))
        {
            return string.Empty;
        }

        if (string.IsNullOrEmpty(fileName))
        {
            fileName = GetLastAccessFileName();
        }

        if (File.Exists(fileName))
        {
            FileInfo fileInfo = new FileInfo(fileName);
            if (fileInfo.Length > fileMaxSize)
            {
                fileName = GetDefaultFileName();
            }
            else
            {
                currentFileSize = (int)fileInfo.Length;
            }
        }

        return fileName;
    }

    private string GetLastAccessFileName()
    {
        string[] existLogFileNames = GetExistLogFileNames();
        foreach (string result in existLogFileNames)
        {
            FileInfo fileInfo = new FileInfo(result);
            if (fileInfo.Length < fileMaxSize) // 判断已创建的日志文件是否达到最大内存
            {
                currentFileSize = (int)fileInfo.Length;
                return result;
            }
        }

        return GetDefaultFileName(); // 若未创建过,通过指定方式创建
    }

    private string GetDefaultFileName()
    {
        switch (generateMode)
        {
            case GenerateMode.ByEveryMinute:
                return Path.Combine(filePath, "Logs_" + DateTime.Now.ToString("yyyyMMdd_HHmm") + ".txt");
            case GenerateMode.ByEveryHour:
                return Path.Combine(filePath, "Logs_" + DateTime.Now.ToString("yyyyMMdd_HH") + ".txt");
            case GenerateMode.ByEveryDay:
                return Path.Combine(filePath, "Logs_" + DateTime.Now.ToString("yyyyMMdd") + ".txt");
            case GenerateMode.ByEveryWeek:
                {
                    GregorianCalendar gregorianCalendar = new GregorianCalendar();
                    int weekOfYear = gregorianCalendar.GetWeekOfYear(DateTime.Now, CalendarWeekRule.FirstDay, DayOfWeek.Monday);
                    return Path.Combine(filePath, "Logs_" + DateTime.Now.Year + "_W" + weekOfYear + ".txt");
                }
            case GenerateMode.ByEveryMonth:
                return Path.Combine(filePath, "Logs_" + DateTime.Now.ToString("yyyy_MM") + ".txt");
            case GenerateMode.ByEverySeason:
                return Path.Combine(filePath, "Logs_" + DateTime.Now.Year + "_Q" + (DateTime.Now.Month / 3 + 1) + ".txt");
            case GenerateMode.ByEveryYear:
                return Path.Combine(filePath, "Logs_" + DateTime.Now.Year + ".txt");
            default:
                return string.Empty;
        }
    }

    public override string ToString()
    {
        return $"LogNetFileSize[{fileMaxSize}];LogNetDateTime[{generateMode}]";
    }
}
/// <summary>
/// 消息级别
/// </summary>
public enum MessageDegree
{
    DEBUG = 1,
    INFO = 2,
    WARN = 3,
    ERROR = 4,
    FATAL = 5,
    None = 9
}

参考: C# 日志记录分级功能使用 按照日期,大小,或是单文件存储

三、通过开源库 NLog 实现通过配置文件配置日志选项

NLog 是一个基于 .net 平台编写的日志记录类库,我们可以使用 NLog 在应用程序中添加极为完善的跟踪调试代码。

本文将通过日志框架 Nlog 和 ConcurrentQueue 队列,实现一个高性能的日志库。

首先,为什么相中了 Nlog ?

  • NLog 是适用于各个 .net 平台的灵活且免费的日志记录平台。通过 NLog, 可以轻松地写入多个目标(例如:数据库、文件、控制台等), 并可动态更改日志记录配置信息。
  • NLog 支持结构化和传统日志记录。
  • NLog 的特点: 高性能、易于使用、易于扩展和灵活配置。

ConcurrentQueue:表示线程安全的先进先出(FIFO)集合。所有公共成员和受保护成员 ConcurrentQueue<T> 都是线程安全的,可以从多个线程并发使用。

1. 配置文件

对于 ASP.NET 应用程序,存在嵌入程序配置文件和单独配置文件两种方式,程序在启动时,会在应用程序主目录下依次查找:
web.config(*.exe.config、*.web.config)、web.nlog(*.exe.nlog)、NLog.config

个人推荐单独文件配置,便于修改和迭代使用。

第一种方式:单独配置文件

常用名称为 NLog.config。此时需要在根节点 nlog 加上智能感知(Intellisense)的属性配置,详见下文配置文件 XML 代码。

1/5 targets(必须有) - 定义日志目标/输出

  • name:是指的输出地方的一个名词(给 rules 调用的);
  • xsi:type:输出文件的类型,File 指的是文件,Console 控制台输出;
  • fileName:输出到目标文件的地址,使用的相对路径,可以自行配置输出的地点。
  • layout:在最简单的形式中,布局是带有嵌入标记的文本,这些嵌入标记样子例如:${xxxx};
  • archiveFileName:表示
    滚动日志
    存放路径;
  • archiveAboveSize:单次日志的存储大小
    (单位是KB)
    ,超过配置,会 archiveFileName 中创建新的日志;
  • archiveNumbering:Sequence(排序),Rolling(滚动);
  • concurrentWrites:支持多个并发一起写文件,提高文件写入性能;
  • keepFileOpen:为了提高文件写入性能,避免每次写入文件都开关文件;
  • autoFlush:为了提高日志写入性能,不必每次写入日志都直接写入到硬盘;
  • createDirs:若设置的日志文件夹不存在,则自动创建文件夹,true:创建;false:不创建。

其中,
layout 属性的标记变量(${xxx})解析
可以参考以下代码:

点击展开 查看标记释义
${cached} - 将缓存应用于另一个布局输出。
${db-null} - 为数据库呈现 DbNull
${exception} - 通过调用记录器方法之一提供的异常信息
${level} - 日志级别(例如错误、调试)或级别序号(数字)
${literal} - 字符串 literal。(文本) - 用于转义括号
${logger} - 记录器名称。GetLogger, GetCurrentClassLogger 等
${message} - (格式化的)日志消息。
${newline} - 换行符文字。
${object-path} - 呈现对象的(嵌套)属性
${onexception} - 仅在为日志消息定义了异常时才输出内部布局。
${onhasproperties} - 仅当事件属性包含在日志事件中时才输出内部布局。
${var} - 渲染变量

// 调用站点和堆栈跟踪
${callite} - 调用站点(类名、方法名和源信息)
${callite-filename} - 调用站点源文件名。
${callsite-linenumber} - 呼叫站点源行编号。
${stacktrace} - Render the Stack trace

// 条件
${when} - 仅在满足指定条件时输出内部布局。
${whenempty} - 当内部布局生成空结果时输出备用布局。

// 上下文信息
${activity} - 从 System.Diagnostics.Activity.Current NLog.DiagnosticSource External 捕获跟踪上下文
${activityid} - 将 System.Diagnostics 跟踪关联 ID 放入日志中。
${all-event-properties} - 记录所有事件上下文数据。
${event-context} - 记录事件属性数据 - 替换为 ${事件属性}
${event-properties} - 记录事件属性数据 - 重命名 ${事件-上下文}
${gdc} - 全局诊断上下文项。用于保存每个应用程序实例值的字典结构。
${install-context} - 安装参数(传递给 InstallNLogConfig)。
${mdc} - 映射的诊断上下文 - 线程本地结构。
${mdlc} - 异步映射诊断上下文 - 作用域内上下文的线程本地结构。MDC 的异步版本。
${ndc} - 嵌套诊断上下文 - 线程本地结构。
${ndlc} - 异步嵌套诊断上下文 - 线程本地结构。

// 计数器
${counter} - 计数器值(在每次布局呈现时增加)
${guid} - 全局唯一标识符(GUID)。
${sequenceid} - 日志序列 ID

// 日期和时间
${date} - 当前日期和时间。
${longdate} - 日期和时间采用长而可排序的格式"yyyy-MM-dd HH:mm:ss.ffff"。
${qpc} - 高精度计时器,基于从 QueryPerformanceCounter 返回的值。
${shortdate} - 可排序格式为 yyyy-MM-dd 的短日期。
${ticks} - 当前日期和时间的分笔报价值。
${time} - 24 小时可排序格式的时间 HH:mm:ss.mmm。

// 编码和字符串转换
${json-encode} - 使用 JSON 规则转义另一个布局的输出。
${left} - 文本的剩余部分
${lowercase} - 将另一个布局输出的结果转换为小写。
${norawvalue} - 防止将另一个布局呈现器的输出视为原始值
${pad} - 将填充应用于另一个布局输出。
${replace} - 将另一个布局输出中的字符串替换为另一个字符串。使用正则表达式可选
${replace-newlines} - 将换行符替换为另一个字符串。
${right} - 文本的右侧部分
${rot13} - 使用 ROT-13 解码"加密"的文本。
${substring} - 文本的子字符串
${trim-whitespace} - 从另一个布局呈现器的结果中修剪空格。
${uppercase} - 将另一个布局输出的结果转换为大写。
${url-encode} - 对另一个布局输出的结果进行编码,以便与 URL 一起使用。
${wrapline} - 以指定的行长度换行另一个布局输出的结果。
${xml-encode} - 将另一个布局输出的结果转换为符合 XML 标准。

// 环境和配置文件
${appsetting} - 来自 .config 文件 NLog.Extended 的应用程序配置设置
${configsetting} - 来自 appsettings.json 的值或 ASP.NET Core & .NET Core NLog.Extensions.LoggingNLog.Extensions.HostingNLog.Web.AspNetCore
${environment} - 环境变量。(例如 PATH、OSVersion)
${environment-user} - 用户标识信息(用户名)。
${registry} - 来自 Windows 注册表的值。

// 文件和目录
${basedir} - 当前应用程序域的基目录。
${currentdir} - 应用程序的当前工作目录。
${dir-separator} - 操作系统相关目录分隔符。
${file-contents} - 呈现指定文件的内容。
${filesystem-normalize} - 通过将文件名中不允许使用的字符替换为安全字符来筛选它们。
${nlogdir} - NLog.dll所在的目录。
${processdir} - 应用程序的可执行进程目录。
${specialfolder} - 系统特殊文件夹路径(包括"我的文档"、"我的音乐"、"程序文件"、"桌面"等)。
${tempdir} - 一个临时目录。

// 身份
${identity} - 线程标识信息(名称和身份验证信息)。
${windows-identity} - Thread Windows identity information (username)
${windows-identity} - Thread Windows identity information (username) Nlog.WindowsIdentity

// 集成
${gelf} - 将 LogEvents 转换为 GELF 格式以发送到 Graylog NLog.GelfLayout External
${log4jxmlevent} - XML 事件描述与 log4j、Chainsaw 和 NLogViewer 兼容。

// 进程、线程和程序集
${appdomain} - 当前应用域。
${assembly-version} - 默认应用程序域中可执行文件的版本。
${gc} - 有关垃圾回收器的信息。
${hostname} - 运行进程的计算机的主机名。
${local-ip} - 来自网络接口的本地 IP 地址。
${machinename} - 运行进程的计算机名称。
${performancecounter} - 性能计数器。
${processid} - 当前进程的标识符。
${processinfo} - 有关正在运行的进程的信息,例如 StartTime、PagedMemorySize
${processname} - 当前进程的名称。
${processtime} - 格式为 HH:mm:ss.mmm 的处理时间。
${threadid} - 当前线程的标识符。
${threadname} - 当前线程的名称。

// 银光
${document-uri} - 承载当前 Silverlight 应用程序的 HTML 页面的 URI。
${sl-appinfo} - 有关 Silverlight 应用程序的信息。

// 网络、ASP.NET 和 ASP.NET 核心
${aspnet-appbasepath} - ASP.NET Application base path (Content Root) NLog.WebNLog.Web.AspNetCore
${aspnet-application} - ASP.NET Application variable. NLog.Web
${aspnet-environment} - ASP.NET Environment name NLog.Web.AspNetCore
${aspnet-item} - ASP.NET 'HttpContext' item variable. NLog.WebNLog.Web.AspNetCore
${aspnet-mvc-action} - ASP.NET MVC Action Name from routing parameters NLog.WebNLog.Web.AspNetCore
${aspnet-mvc-controller} - ASP.NET MVC Controller Name from routing parameters NLog.WebNLog.Web.AspNetCore
${aspnet-request} - ASP.NET Request variable. NLog.WebNLog.Web.AspNetCore
${aspnet-request-contenttype} - ASP.NET Content-Type header (Ex. application/json) NLog.Web.AspNetCore
${aspnet-request-cookie} - ASP.NET Request cookie content. NLog.WebNLog.Web.AspNetCore
${aspnet-request-form} - ASP.NET Request form content. NLog.WebNLog.Web.AspNetCore
${aspnet-request-headers} - ASP.NET Header key/value pairs. NLog.Web.Web.AspNetCore
${aspnet-request-host} - ASP.NET Request host. NLog.WebNLog.Web.AspNetCore
${aspnet-request-ip} - Client IP. NLog.WebNLog.Web.AspNetCore
${aspnet-request-method} - ASP.NET Request method (GET, POST etc). NLog.WebNLog.Web.AspNetCore
${aspnet-request-posted-body} - ASP.NET posted body / payload NLog.WebNLog.Web.AspNetCore
${aspnet-request-querystring} - ASP.NET Request querystring. NLog.WebNLog.Web.AspNetCore
${aspnet-request-referrer} - ASP.NET Request referrer. NLog.WebNLog.Web.AspNetCore
${aspnet-request-routeparameters} - ASP.NET Request route parameters. NLog.WebNLog.Web.AspNetCore
${aspnet-request-url} - ASP.NET Request URL. NLog.WebNLog.Web.AspNetCore
${aspnet-request-useragent} - ASP.NET Request useragent. NLog.WebNLog.Web.AspNetCore
${aspnet-response-statuscode} - ASP.NET Response status code content. NLog.WebNLog.Web.AspNetCore
${aspnet-session} - ASP.NET Session variable. NLog.WebNLog.Web.AspNetCore
${aspnet-sessionid} - ASP.NET Session ID variable. NLog.WebNLog.Web.AspNetCore
${aspnet-traceidentifier} - ASP.NET trace identifier NLog.WebNLog.Web.AspNetCore
${aspnet-user-authtype} - ASP.NET User auth. NLog.WebNLog.Web.AspNetCore
${aspnet-user-claim} - ASP.NET User Claims 授权值 NLog.Web.AspNetCore
${aspnet-user-identity} - ASP.NET User variable. NLog.WebNLog.Web.AspNetCore
${aspnet-user-isauthenticated} - ASP.NET User authenticated? NLog.WebNLog.Web.AspNetCore
${aspnet-webrootpath} - ASP.NET Web root path (wwwroot) NLog.WebNLog.Web.AspNetCore
${iis-site-name} - IIS site name. NLog.WebNLog.Web.AspNetCore

//参考: https://www.cnblogs.com/zmy2020/p/15936886.html

2/5 rules(必须有) - 定义日志路由规则

rules 下只有一种节点 logger(可同时配置多个),其属性释义如下:

  • name:logger 名称,若为 * 则表示适用于所有日志,?:匹配单个字符;
  • minlevel:表示记录的最低日志级别,只有大于等于该日志级别才会被记录;
  • maxlevel:记录的最高级别;
  • level:单极记录,只记录一个级别日志;
  • levels:同时记录多个级别的日志,用逗号分隔;
  • writeTo:和 target 节点的 name 属性值匹配,一个 rules 对应一个 target;
  • enabled:通过值为 false 禁用规则,而不用删除;
  • ruleName:规则标识符,允许使用 Configuration.FindRuleByName 和进行规则查找 Configuration.RemoveRuleByName,在 NLog 4.6.4 中引入。

3/5 variables - 声明变量的值

variable 元素定义了
配置文件中需要用到的变量
,一般用来
表示复杂或者重复的表达式
(例如文件名)。变量需要先定义后使用,否则配置文件将初始化失败。

  • name:变量名;
  • value:变量值。

定义变量之后,可以通过
${my_name}
语法来使用。

4/5 extensions - 定义要加载的 NLog 扩展项 *.dll 文件

extensions 节点可以添加额外的 NLog 元包或自定义功能,assembly 属性指定的被包含程序集不带后缀 .dll 。示例如下:

<nlog>
    <extensions> 
        <add assembly="MyAssembly" />
    </extensions>
    <targets>
        <target name="a1" type="MyFirst" host="localhost" />
    </targets>
    <rules>
        <logger name="*" minLevel="Info" appendTo="a1" />
    </rules>
</nlog>

NLog 4.0 之后,与
NLog.dll
同目录下名如
NLog*.dll
的程序集(如:
NLog.CustomTarget.dll
)会被自动加载。

5/5 includes - 指定当前配置文件包含多个子配置文件

通过 ${} 语法可以使用环境变量,下例展示包含一个名为当前机器名的配置文件。

<nlog>
    ...
    <include file="${machinename}.config" />
    ...
</nlog>

NLog 4.4.2 之后可以使用通配符
*
指定多个文件。例如:
<include file="nlog-*.config" />

示例配置:

<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.nlog-project.org/schemas/NLog.xsd NLog.xsd"
        autoReload="true"
        throwExceptions="false"
        internalLogLevel="Off" internalLogFile="c:\temp\nlog-internal.log">
    <variable name="appName" value="ConsoleAppDemo"/>
    <targets>
    <target name="logconsole" xsi:type="Console"
				layout="${longdate} [${uppercase:${level}}] ${callsite}(${callsite-filename:includeSourcePath=False}:${callsite-linenumber}) - ${message} ${exception:format=ToString}"
		/>
    <target name="logfile"
				xsi:type="File"
				fileName="${basedir}/logs/${appName}-${shortdate}.log"
				layout="${longdate} [${uppercase:${level}}] ${callsite}(${callsite-filename:includeSourcePath=False}:${callsite-linenumber}) - ${message} ${exception:format=ToString}"
				maxArchiveFiles="999"
				archiveFileName="${basedir}/logs/${appName}-${shortdate}-${###}.log"
				createDirs="true"
				archiveAboveSize="102400"
				archiveEvery="Day"
				encoding="UTF-8"
		/>
    </targets>
    <rules>
    <logger name="*" minlevel="Debug" writeTo="logfile" />
    </rules>
</nlog>

参考: 完善 .Net Core 项目 — NLog入门 (日志组件)

第二种方式:嵌入程序配置文件

NLog 配置信息可以嵌入在 .net 应用程序自身的配置文件中,例如 *.exe.config 或者 *.web.config 中,需要使用 configSections 节点配置,如下 XML 代码,再将其他配置填入 nlog 节点即可。

nlog 节点内的内容,参考前边‘第一种方式’。

<configuration>
  <configSections>
    <section name="nlog" type="NLog.Config.ConfigSectionHandler, NLog"/>
  </configSections>
  <nlog>
      ......
  </nlog>
</configuration>

2. 测试代码

static void Main(string[] args)
{
    try
    {
        LoggerHelper._.Info($"完成");
        LoggerHelper._.Debug($"Debug完成");
        LoggerHelper._.Error($"Error完成");
        throw (new Exception());
    }
    catch (Exception ex)
    {
        LoggerHelper._.Error(ex.Message);
    }
}
// 输出日志
2023-04-04 17:14:45.6651 [INFO] YOKAVerse.Net.Log.LoggerHelper.Info(Logger.cs:40) - 完成 
2023-04-04 17:14:46.7303 [DEBUG] YOKAVerse.Net.Log.LoggerHelper.Debug(Logger.cs:28) - Debug完成 
2023-04-04 17:14:47.2924 [ERROR] YOKAVerse.Net.Log.LoggerHelper.Error(Logger.cs:76) - Error完成 
2023-04-04 17:14:49.5869 [ERROR] YOKAVerse.Net.Log.LoggerHelper.Error(Logger.cs:76) - Exception of type 'System.Exception' was thrown. 

3. 日志记录类

以下代码对 NLog 进行了封装,
将日志记录先存在线程安全的队列里,以避免调用写入文件时 I/O 的耗时操作拖垮应用程序

队列有两个,一个是操作队列-concurrentQueue_operation,一个是助手队列-concurrentQueue_assistant,程序中的日志记录需求直接写入助手队列,避免影响程序频繁写入造成的系统等待。当操作队列中的记录处理完成后,再将助手队列的记录转至操作队列,继续进行比较耗时的写入操作。

当然这种方法在提高系统响应速度的同时,也存在一个弊端,就是在程序崩溃而异常退出时,
可能造成积压在队列中的日志记录未全部完成落地
,导致日志内容丢失。所以使用时还请权衡利弊,慎重使用。

public class LoggerHelper
{
    /// <summary>
    /// 实例化nLog,即为获取配置文件相关信息(获取以当前正在初始化的类命名的记录器)
    /// </summary>
    private readonly NLog.Logger logger = LogManager.GetCurrentClassLogger();
    private static LoggerHelper _obj;
    /// <summary>
    /// 辅助队列
    /// </summary>
    private static ConcurrentQueue<LogModel> concurrentQueue_assistant = new ConcurrentQueue<LogModel>();
    /// <summary>
    /// 操作队列
    /// </summary>
    private static ConcurrentQueue<LogModel> concurrentQueue_operation = new ConcurrentQueue<LogModel>();
    private static string lockobj_assistant = string.Empty;
    private static string lockobj_operation = string.Empty;

    public static LoggerHelper LHR
    {
        get => _obj ?? (_obj = new LoggerHelper());
        set => _obj = value;
    }

    public LoggerHelper()
    {
        InitializeTask();
    }

    private static LogModel logModel_init = null;
    /// <summary>
    /// 初始化后台线程
    /// </summary>
    private void InitializeTask()
    {
        if (logModel_init == null)
        {
            logModel_init = new LogModel();
            Thread t = new Thread(new ThreadStart(LogOperation));
            t.IsBackground = false;
            t.Start();
        }
    }

    /// <summary>
    /// 记录日志
    /// </summary>
    private void LogOperation()
    {
        while (true) // 线程持续处理
        {
            if (concurrentQueue_assistant.Count > 0 && concurrentQueue_operation.Count == 0)
            {
                lock (lockobj_assistant)
                {
                    concurrentQueue_operation = concurrentQueue_assistant; // 将数据转至操作队列
                    concurrentQueue_assistant = new ConcurrentQueue<LogModel>(); // 注意此处不可用 .Clear() 因为 ConcurrentQueue<T> 为引用类型
                }
                LogModel logModel;
                // 取出队列 concurrentQueue_operation 中待写入的日志记录,直至全部记录完成
                while (concurrentQueue_operation.Count > 0 && concurrentQueue_operation.TryDequeue(out logModel))
                {
                    switch (logModel.type) // 日志类型分流
                    {
                        case NLogLevel.Trace:
                            if (logModel.exobj != null)
                                logger.Trace(logModel.content);
                            else
                                logger.Trace(logModel.content, logModel.exobj);
                            break;
                        case NLogLevel.Debug:
                            if (logModel.exobj != null)
                                logger.Debug(logModel.content);
                            else
                                logger.Debug(logModel.content, logModel.exobj);
                            break;
                        case NLogLevel.Info:
                            if (logModel.exobj != null)
                                logger.Info(logModel.content, logModel.exobj);
                            else
                                logger.Info(logModel.content);
                            break;
                        case NLogLevel.Error:
                            if (logModel.exobj != null)
                                logger.Error(logModel.content, logModel.exobj);
                            else
                                logger.Error(logModel.content);
                            break;
                        case NLogLevel.Warn:
                            if (logModel.exobj != null)
                                logger.Warn(logModel.content, logModel.exobj);
                            else
                                logger.Warn(logModel.content);
                            break;
                        case NLogLevel.Fatal:
                            if (logModel.exobj != null)
                                logger.Fatal(logModel.content, logModel.exobj);
                            else
                                logger.Fatal(logModel.content);
                            break;
                        default:
                            break;
                    }
                }
            }
            else
                Thread.Sleep(1000);
        }
    }

    /// <summary>
    /// 加入队列前,根据日志级别统一验证
    /// </summary>
    /// <param name="logModel"></param>
    public void EnqueueLogModel(LogModel logModel)
    {
        if ((logModel.type == NLogLevel.Trace && logger.IsTraceEnabled) || (logModel.type == NLogLevel.Debug && logger.IsDebugEnabled)
            || (logModel.type == NLogLevel.Info && logger.IsInfoEnabled) || (logModel.type == NLogLevel.Warn && logger.IsWarnEnabled)
            || (logModel.type == NLogLevel.Error && logger.IsErrorEnabled) || (logModel.type == NLogLevel.Fatal && logger.IsFatalEnabled))
        {
            lock (lockobj_assistant)
            {
                concurrentQueue_assistant.Enqueue(logModel);
            }
        }
    }

    /// <summary>
    /// Trace,追踪,非常详细的日志,该日志等级通常仅在开发过程中被使用
    /// </summary>
    /// <param name="msg"></param>
    public void Trace(string logcontent)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Trace, content = logcontent });
    }
    public void Trace(string logcontent, Exception exception)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Trace, content = logcontent, exobj = exception });
    }

    /// <summary>
    /// Debug,调试,详尽信息次于 Trace,在生产环境中通常不启用
    /// </summary>
    /// <param name="msg"></param>
    public void Debug(string logcontent)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Debug, content = logcontent });
    }
    public void Debug(string logcontent, Exception exception)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Debug, content = logcontent, exobj = exception });
    }

    /// <summary>
    /// Info,信息,通常在生产环境中通常启用
    /// </summary>
    /// <param name="msg"></param>
    public void Info(string logcontent)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Info, content = logcontent });
    }
    public void Info(string logcontent, Exception exception)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Info, content = logcontent, exobj = exception });
    }

    /// <summary>
    /// Warn,警告,通常用于非关键问题,这些问题可以恢复,或者是暂时的故障
    /// </summary>
    /// <param name="msg"></param>
    public void Warn(string logcontent)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Warn, content = logcontent });
    }
    public void Warn(string logcontent, Exception exception)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Warn, content = logcontent, exobj = exception });
    }

    /// <summary>
    /// Error,错误,多数情况下记录Exceptions(异常)信息
    /// </summary>
    /// <param name="msg"></param>
    public void Error(string logcontent)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Error, content = logcontent });
    }
    public void Error(string logcontent, Exception exception)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Error, content = logcontent, exobj = exception });
    }

    /// <summary>
    /// Fatal,致命错误,非常严重的错误
    /// </summary>
    /// <param name="msg"></param>
    public void Fatal(string logcontent)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Fatal, content = logcontent });
    }
    public void Fatal(string logcontent, Exception exception)
    {
        EnqueueLogModel(new LogModel() { type = NLogLevel.Fatal, content = logcontent, exobj = exception });
    }
}
public class LogModel
{
    public NLogLevel type { get; set; }
    public string content { get; set; }
    public Exception exobj { get; set; }
}
/// <summary>
/// NLog 日志等级
/// </summary>
public enum NLogLevel
{
    Trace,
    Debug,
    Info,
    Warn,
    Error,
    Fatal
}

参考: C# 超高速高性能写日志 代码开源 .net core 中的那些常用的日志框架(NLog篇)

四、日志查看器

作为一名研发人员,高效率的日志分析是必须的,当然好的工具也是前提条件。

要想高效分析日志,有几个问题需要解决:

  • 快速定位,在海量日志信息中
    快速定位
    目标行;
  • 高亮显示,以不同颜色显示目标行,以便分类提高辨识度;
  • 只显示有用的行。

在日常开发使用最多的莫过于 NotePad++ 了,尽管其可以通过 “搜索-标记/标记所有-使用格式1/2/3/4/5”的操作来实现以上的前两点,但是操作较繁琐,当日志行数比较多时,也无法仅显示标记行,从而造成效率低下。

当然,对于普通的业务量不太高的日志记录,NotePad++ 足以满足使用。

下面介绍一个非常简单实用的开源日志查看工具 TextAnalysisTool.NET。

1. 下载应用程序包

下载完成后,如下图打开最新版的应用程序:

2. 分析的日志文件

按照“File -> Open”选择要打开的日志文件。

双击任意行,便会跳出“Add Filter”窗口:(Text 默认为鼠标焦点行的内容)

可以通过修改
“Text Color”和“Background”
来指定查询结果的
文本和行底色
,达到高亮显示目的。

其他选项:
Description
:描述;
Excluding
:排除,不包含;
Case-sensitive
:大小写敏感;
Regular-expression
:按照正则表达式查询。

如下图示例,查询三个语句,标志为不同的行底色效果:

若想
只显示查询目标所在的行
,可以如下图鼠标操作,也可使用快捷键 Ctrl+H,取消时重复操作即可。

参考: 使用TextAnalysisTool来快速提高你分析文本日志的效率

作者:京东科技 董健

导读

缓存Redis,是我们最常用的服务,其适用场景广泛,被大量应用到各业务场景中。也正因如此,缓存成为了重要的硬件成本来源,我们有必要从空间上做一些优化,降低成本的同时也会提高性能。

下面以我们的案例说明,将缓存空间减少70%的做法。

场景设定

1、我们需要将POJO存储到缓存中,该类定义如下

public class TestPOJO implements Serializable {
    private String testStatus;
    private String userPin;
    private String investor;
    private Date testQueryTime;
    private Date createTime;
    private String bizInfo;
    private Date otherTime;
    private BigDecimal userAmount;
    private BigDecimal userRate;
    private BigDecimal applyAmount;
    private String type;
    private String checkTime;
    private String preTestStatus;
    
    public Object[] toValueArray(){
        Object[] array = {testStatus, userPin, investor, testQueryTime,
                createTime, bizInfo, otherTime, userAmount,
                userRate, applyAmount, type, checkTime, preTestStatus};
        return array;
    }
    
    public CreditRecord fromValueArray(Object[] valueArray){         
        //具体的数据类型会丢失,需要做处理
    }
}

2、用下面的实例作为测试数据

TestPOJO pojo = new TestPOJO();
pojo.setApplyAmount(new BigDecimal("200.11"));
pojo.setBizInfo("XX");
pojo.setUserAmount(new BigDecimal("1000.00"));
pojo.setTestStatus("SUCCESS");
pojo.setCheckTime("2023-02-02");
pojo.setInvestor("ABCD");
pojo.setUserRate(new BigDecimal("0.002"));
pojo.setTestQueryTime(new Date());
pojo.setOtherTime(new Date());
pojo.setPreTestStatus("PROCESSING");
pojo.setUserPin("ABCDEFGHIJ");
pojo.setType("Y");

常规做法

System.out.println(JSON.toJSONString(pojo).length());

使用JSON直接序列化、打印
length=284****,
这种方式是最简单的方式,也是最常用的方式,具体数据如下:

{"applyAmount":200.11,"bizInfo":"XX","checkTime":"2023-02-02","investor":"ABCD","otherTime":"2023-04-10 17:45:17.717","preCheckStatus":"PROCESSING","testQueryTime":"2023-04-10 17:45:17.717","testStatus":"SUCCESS","type":"Y","userAmount":1000.00,"userPin":"ABCDEFGHIJ","userRate":0.002}

我们发现,以上包含了大量无用的数据,其中属性名是没有必要存储的。

改进1-去掉属性名

System.out.println(JSON.toJSONString(pojo.toValueArray()).length());

通过选择数组结构代替对象结构,去掉了属性名,打印
length=144
,将数据大小降低了50%,具体数据如下:

["SUCCESS","ABCDEFGHIJ","ABCD","2023-04-10 17:45:17.717",null,"XX","2023-04-10 17:45:17.717",1000.00,0.002,200.11,"Y","2023-02-02","PROCESSING"]

我们发现,null是没有必要存储的,时间的格式被序列化为字符串,不合理的序列化结果,导致了数据的膨胀,所以我们应该选用更好的序列化工具。

改进2-使用更好的序列化工具

//我们仍然选取JSON格式,但使用了第三方序列化工具
System.out.println(new ObjectMapper(new MessagePackFactory()).writeValueAsBytes(pojo.toValueArray()).length);

选取更好的序列化工具,实现字段的压缩和合理的数据格式,打印
length=92,
空间比上一步又降低了40%。

这是一份二进制数据,需要以二进制操作Redis,将二进制转为字符串后,打印如下:

��SUCCESS�ABCDEFGHIJ�ABCD��j�6���XX��j�6����?`bM����@i��Q�Y�2023-02-02�PROCESSING

顺着这个思路再深挖,我们发现,可以通过手动选择数据类型,实现更极致的优化效果,选择使用更小的数据类型,会获得进一步的提升。

改进3-优化数据类型

在以上用例中,testStatus、preCheckStatus、investor这3个字段,实际上是枚举字符串类型,如果能够使用更简单数据类型(比如byte或者int等)替代string,还可以进一步节省空间。其中checkTime可以用Long类型替代字符串,会被序列化工具输出更少的字节。

public Object[] toValueArray(){
    Object[] array = {toInt(testStatus), userPin, toInt(investor), testQueryTime,
    createTime, bizInfo, otherTime, userAmount,
    userRate, applyAmount, type, toLong(checkTime), toInt(preTestStatus)};
    return array;
}

在手动调整后,使用了更小的数据类型替代了String类型,打印
length=69

改进4-考虑ZIP压缩

除了以上的几点之外,还可以考虑使用ZIP压缩方式获取更小的体积,在内容较大或重复性较多的情况下,ZIP压缩的效果明显,如果存储的内容是TestPOJO的数组,可能适合使用ZIP压缩。

但ZIP压缩并不一定会减少体积,在小于30个字节的情况下,也许还会增加体积。在重复性内容较少的情况下,无法获得明显提升。并且存在CPU开销。

在经过以上优化之后,ZIP压缩不再是必选项,需要根据实际数据做测试才能分辨到ZIP的压缩效果。

最终落地

上面的几个改进步骤体现了优化的思路,但是反序列化的过程会导致类型的丢失,处理起来比较繁琐,所以我们还需要考虑反序列化的问题。

在缓存对象被预定义的情况下,我们完全可以手动处理每个字段,所以在实战中,推荐使用手动序列化达到上述目的,实现精细化的控制,达到最好的压缩效果和最小的性能开销。

可以参考以下msgpack的实现代码,以下为测试代码,请自行封装更好的Packer和UnPacker等工具:

<dependency>    
    <groupId>org.msgpack</groupId>    
    <artifactId>msgpack-core</artifactId>    
    <version>0.9.3</version>
</dependency>
    public byte[] toByteArray() throws Exception {
        MessageBufferPacker packer = MessagePack.newDefaultBufferPacker();
        toByteArray(packer);
        packer.close();
        return packer.toByteArray();
    }

    public void toByteArray(MessageBufferPacker packer) throws Exception {
        if (testStatus == null) {
            packer.packNil();
        }else{
            packer.packString(testStatus);
        }

        if (userPin == null) {
            packer.packNil();
        }else{
            packer.packString(userPin);
        }

        if (investor == null) {
            packer.packNil();
        }else{
            packer.packString(investor);
        }

        if (testQueryTime == null) {
            packer.packNil();
        }else{
            packer.packLong(testQueryTime.getTime());
        }

        if (createTime == null) {
            packer.packNil();
        }else{
            packer.packLong(createTime.getTime());
        }

        if (bizInfo == null) {
            packer.packNil();
        }else{
            packer.packString(bizInfo);
        }

        if (otherTime == null) {
            packer.packNil();
        }else{
            packer.packLong(otherTime.getTime());
        }

        if (userAmount == null) {
            packer.packNil();
        }else{
            packer.packString(userAmount.toString());
        }

        if (userRate == null) {
            packer.packNil();
        }else{
            packer.packString(userRate.toString());
        }

        if (applyAmount == null) {
            packer.packNil();
        }else{
            packer.packString(applyAmount.toString());
        }

        if (type == null) {
            packer.packNil();
        }else{
            packer.packString(type);
        }

        if (checkTime == null) {
            packer.packNil();
        }else{
            packer.packString(checkTime);
        }

        if (preTestStatus == null) {
            packer.packNil();
        }else{
            packer.packString(preTestStatus);
        }
    }


    public void fromByteArray(byte[] byteArray) throws Exception {
        MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(byteArray);
        fromByteArray(unpacker);
        unpacker.close();
    }

    public void fromByteArray(MessageUnpacker unpacker) throws Exception {
        if (!unpacker.tryUnpackNil()){
            this.setTestStatus(unpacker.unpackString());
        }
        if (!unpacker.tryUnpackNil()){
            this.setUserPin(unpacker.unpackString());
        }
        if (!unpacker.tryUnpackNil()){
            this.setInvestor(unpacker.unpackString());
        }
        if (!unpacker.tryUnpackNil()){
            this.setTestQueryTime(new Date(unpacker.unpackLong()));
        }
        if (!unpacker.tryUnpackNil()){
            this.setCreateTime(new Date(unpacker.unpackLong()));
        }
        if (!unpacker.tryUnpackNil()){
            this.setBizInfo(unpacker.unpackString());
        }
        if (!unpacker.tryUnpackNil()){
            this.setOtherTime(new Date(unpacker.unpackLong()));
        }
        if (!unpacker.tryUnpackNil()){
            this.setUserAmount(new BigDecimal(unpacker.unpackString()));
        }
        if (!unpacker.tryUnpackNil()){
            this.setUserRate(new BigDecimal(unpacker.unpackString()));
        }
        if (!unpacker.tryUnpackNil()){
            this.setApplyAmount(new BigDecimal(unpacker.unpackString()));
        }
        if (!unpacker.tryUnpackNil()){
            this.setType(unpacker.unpackString());
        }
        if (!unpacker.tryUnpackNil()){
            this.setCheckTime(unpacker.unpackString());
        }
        if (!unpacker.tryUnpackNil()){
            this.setPreTestStatus(unpacker.unpackString());
        }
    }

场景延伸

假设,我们为2亿用户存储数据,每个用户包含40个字段,字段key的长度是6个字节,字段是分别管理的。

正常情况下,我们会想到hash结构,而hash结构存储了key的信息,会占用额外资源,字段key属于不必要数据,按照上述思路,可以使用list替代hash结构。

通过Redis官方工具测试,使用list结构需要144G的空间,而使用hash结构需要245G的空间
(当50%以上的属性为空时,需要进行测试,是否仍然适用)

在以上案例中,我们采取了几个非常简单的措施,仅仅有几行简单的代码,可降低空间70%以上,在数据量较大以及性能要求较高的场景中,是非常值得推荐的。:

• 使用数组替代对象(如果大量字段为空,需配合序列化工具对null进行压缩)

• 使用更好的序列化工具

• 使用更小的数据类型

• 考虑使用ZIP压缩

• 使用list替代hash结构(如果大量字段为空,需要进行测试对比)

入职多年,面对生产环境,尽管都是小心翼翼,慎之又慎,还是难免捅出篓子。轻则满头大汗,面红耳赤。重则系统停摆,损失资金。每一个生产事故的背后,都是宝贵的经验和教训,都是项目成员的血泪史。为了更好地防范和遏制今后的各类事故,特开此专题,长期更新和记录大大小小的各类事故。有些是亲身经历,有些是经人耳传口授,但无一例外都是真实案例。

注意:为了避免不必要的麻烦和商密问题,文中提到的特定名称都将是化名、代称。

0x00 大纲

0x01 事故背景

2021年11月26日01时10分,P公司正在进行某业务系统的生产环境部署操作,但其实早在00时30分的时候,他们已经完成过一次部署了,但是奇怪的是无论如何都通不过验证,无奈只好推倒重来,如此反复了有若干次。为何反复尝试,却不尝试去寻找问题呢?问题就在于该系统同一份代码在开发环境和 UAT 环境均一切正常,唯独部署到生产环境上面就不行。这是一个前后端分离的业务系统,前端与后端接口基于 JWT 而不是传统 Session 进行鉴权认证。故障的现象也很简单,就是无法登录——准确的说,是登录后不能维持登录状态,一访问其他需要鉴权的资源立马又被重定向到登录页面。2020年10月25日02时30分,在运维人员多次尝试无果,开发人员排查代码也未发现问题后,P公司不得不直呼见鬼。那么真相究竟是什么呢?

0x02 事故分析


RFC 7519
规范中对于 JWT 是这样描述的:

JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code(MAC) and/or encrypted.

JWT (JSON Web Token) 是一种紧凑、URL 安全的表示方式,用于表达要在两个参与方之间传输的安全声明。JWT 中的声明被编码为 JSON 对象,作为 JSON Web Signature (JWS) 结构的有效载荷或 JSON Web Encryption (JWE) 结构的明文,使得声明可以使用消息认证码 (MAC) 进行数字签名或完整性保护和/或加密。

说人话呢意思就是 JWT 是一种安全令牌的标准化实现,用于参与双方之间的可信交互认证。既然不好定位是环境还是代码的问题,不妨先捋一捋 JWT 鉴权认证的过程,看看问题可能发生在哪一步:

JWT鉴权认证流程

  1. 从故障现象来看,步骤①出问题的可能性基本被排除,从前端请求和后端日志来看账号和密码的验证过程已经正确完成;
  2. 那么步骤②有没有可能出问题呢?当时也是怀疑过的,但是使用浏览器的 F12 开发者工具,看到 login 的网络请求响应中已经将后端生成的 JWT 返回来了;
  3. 莫非是步骤③没有将 JWT 正确携带,导致后续验证不通过?但是查看登陆后,对其他接口的请求,里面确实已经携带了步骤②中提供的 JWT,而且数值也一致;
  4. 验证JWT的代码逻辑会不会有问题呢?可能性不大,因为在测试环境和 UAT 环境已经反复验证过。

那么问题还是出在步骤③携带 JWT 这一步。前面分析过前端发起请求时,已经携带了 JWT,那么有没有可能是后端没收到或者收到的值不正确呢?很可惜,后端收到 JWT 后没有打印相关的日志……只有简单的提示验证失败的信息。但其实到这里,已经可以怀疑是环境的问题了,因为同样的代码只在生产环境出错。

随机抽取一个运维小伙子,让他说说生产的系统结构,从他口中得知,生产上除了为了部署多个节点,使用了 Nginx 作为负载均衡和反向代理外,其他地方没有区别。凭借往常的经验呢,P公司的员工们首先呢就没有怀疑过反代和负载会影响这个业务功能,但是我们的理性分析又提示我们问题很有可能出在这里。

不妨找个机器验证一下,安装和生产环境相同版本的 Nginx,然后配置一下反代和负载。对了,这回啊,在后端把打印 JWT 的
Debug
日志加上。然后果不出所料,前端虽然在请求头中携带了 JWT,但是到了后端,却显示没有这个信息,这个头,它丢到哪里去了呢?

0x03 事故原因

前端在步骤③请求头中携带的 JWT 如下,HTTP_HEADER_NAME 为 “JWT_TOKEN”,HTTP_HEADER_VALUE 为 JWT 的值:

JWT_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjpbb2x0...

在后端日志中,除了 JWT_TOKEN 以外,其他的头部信息都正常传递,我们注意到,它的 HTTP_HEADER_NAME 包含了下划线,这是它与众不同的地方。难道是被 Nginx 过滤了?

在 Nginx 的
官方文档
里,有这么一段话:

Missing (disappearing) HTTP Headers

If you do not explicitly set underscores_in_headers on;, NGINX will silently drop HTTP headers with underscores (which are perfectly valid according to the HTTP standard). This is done in order to prevent ambiguities when mapping headers to CGI variables as both dashes and underscores are mapped to underscores during that process.

消失的 HTTP Headers

如果你没有显式设置 underscores_in_headers on;,NGINX 会静悄悄地干掉带有下划线的 HTTP 请求头(虽然它们符合 HTTP 规范,毁灭你与你何干……)。这样做是为了防止在将请求头映射到 CGI 变量时出现歧义,因为在此过程中,短划线和下划线都映射到下划线。


ngx_http_parse.c
中,这个开关是这样处理的:

/* header name */
case sw_name:
    c = lowcase[ch];

    if (c) {
        hash = ngx_hash(hash, c);
        r->lowcase_header[i++] = c;
        i &= (NGX_HTTP_LC_HEADER_LEN - 1);
        break;
    }

    if (ch == '_') {
        if (allow_underscores) {
            hash = ngx_hash(hash, ch);
            r->lowcase_header[i++] = ch;
            i &= (NGX_HTTP_LC_HEADER_LEN - 1);

        } else {
            r->invalid_header = 1;
        }

        break;
    }
    // ……(太长只截取关键部分)
    break;

如果没有开启
underscores_in_headers
开关,对应变量
allow_underscores
,则默认情况下,带有下划线的 HTTP_HEADER 会被标记为 INVALID_HEADER.而标记为 INVALID_HEADER 的信息默认情况下,会被忽略掉,为什么说默认呢?因为这个行为同时还受到另一个开关
ignore_invalid_headers
控制,如果它被开启,那么带有下划线的 HTTP_HEADER 就真的神秘消失了。

关于
underscores_in_headers
选项:

Syntax:
underscores_in_headers on | off;

Default:
underscores_in_headers off;

Context:
http, server

Enables or disables the use of underscores in client request header fields. When the use of underscores is disabled, request header fields whose names contain underscores are marked as invalid and become subject to the ignore_invalid_headers directive.

关于
ignore_invalid_headers
选项:

Syntax:
ignore_invalid_headers on | off;

Default:
ignore_invalid_headers on;

Context:
http, server

Controls whether header fields with invalid names should be ignored. Valid names are composed of English letters, digits, hyphens, and possibly underscores (as controlled by the underscores_in_headers directive).

可以看到
underscores_in_headers
选项默认情况下是关闭的,而
ignore_invalid_headers
选项默认情况下是开启的,这也就导致了我们 JWT_TOKEN 的神秘失踪,至此问题已经定位完毕。

0x04 事故复盘

这次可以说是纯纯的意外,但是这个意外本可以发现的更早:

  • 再穷也好,至少也要申请一个与生产环境相同/相仿的复刻环境。
  • 统一且规范的命名,或许可以避免很多不必要的麻烦。
  • 所谓
    Debug
    日志就是,没事的时候,你看到它嫌它烦;出事的时候,你烦看不到它……
  • 排查问题时,还是大意了,没有去看 Nginx 的日志,因为通过源码可以发现 INVALID_HEADER 默认情况下是会触发 ERROR 日志的:
    if (rc == NGX_OK) {
    
        r->request_length += r->header_in->pos - r->header_name_start;
    
        if (r->invalid_header && cscf->ignore_invalid_headers) {
    
            /* there was error while a header line parsing */
    
            ngx_log_error(NGX_LOG_INFO, c->log, 0,
                          "client sent invalid header line: \"%*s\"",
                          r->header_end - r->header_name_start,
                          r->header_name_start);
            continue;
        }
        // ……(太长只截取关键部分)
    }
    

0x05 事故影响

使P公司新业务系统上线时间延长了3小时,相关人员连夜跟老板申请服务器经费。(知道了,下次还是不批)。

锁的定义

在计算机程序中锁用于独占资源,获取到锁才可以操作对应的资源。

锁的实现

锁在计算机底层的实现,依赖于CPU提供的CAS指令(compare and swsp),对于一个内存地址,会比较原值以及尝试去修改的值,通过值是否修改成功,来表示是否强占到了这个锁。

JVM中的锁

jvm中,有2个常用的锁

synchronized

synchronized是java提供的关键字锁,可以锁对象,类,方法。
在JDK1.6以后,对synchronized进行了优化,增加了偏向锁和轻量锁模式,现在synchronized锁的运行逻辑如下:

  1. 在初始加锁时,会增加偏向锁,即“
    偏向上一次获取该锁的线程
    ”,在偏向锁下,会直接CAS获取该锁。该模式大大提高了单线程反复获取同一个锁的吞吐情况,在Java官方看来,大部分锁的争抢都发生在同个线程上。
  2. 如果偏向锁CAS获取失败,说明当前线程与偏向锁偏向的线程不同,偏向锁就会
    升级
    成轻量锁,轻量锁的特点就是通过
    自旋CAS
    去获取锁。
  3. 如果自旋获取失败,那么锁就会升级成重量锁,所有等待锁的线程将被JVM挂起,在锁释放后,再由JVM统一通知唤醒,再去尝试CAS锁,如果失败,继续挂起。

很显然,偏向锁设计的目的是“在Java官方看来,对同一个锁的争抢大部分都发生在同个线程上”。
轻量锁设计的目的是“在短期内,锁的争抢通过自旋CAS就可以获取到,短时间内的CPU自旋消耗小于线程挂起再唤醒的消耗”。
重量锁就是最初优化前的synchronized的逻辑了。

ReentrantLock

说到ReentrantLock,就不得不说到JUC里的AQS了。
AQS全称AbstractQueueSynchronizer,几乎JUC里所有的工具类,都依赖AQS实现。
AQS在java里,是一个抽象类,但是本质上是一种思路在java中的实现而已。
AQS的实现逻辑如下:

  1. 构造一个队列
  2. 队列中维护需要等待锁的线程
  3. 头结点永远是持有锁(或持有资源)的节点,等待的节点在头结点之后依次连接。
  4. 头结点释放锁后,会按照顺序去唤醒那些等待的节点,然后那些节点会再次去尝试获取锁。

在synchronized锁优化以后,AQS的本质与synchronized并没有太大不同,两者的性能也并没有太大差距了,所以AQS现在的特点是:

  1. 是在java api层面实现的锁,所以可以实现各种并发工具类,操作也更加灵活
  2. 因为提供了超时时间等机制,操作灵活,所以不易死锁。(相同的,如果发生死锁,将更难排查,因为jstack里将不会有deadlock标识)。
  3. 可以实现公平锁,而synchronized必定是非公平锁。
  4. 因为是JavaApi层实现的锁,所以可以响应中断。

到这里你会发现,其实ReentrantLock可以说是synchronized在JavaApi层的实现。

Mysql 锁

共享锁(S) 与排它锁(X)

作用范围

这两种锁都包括行级锁和表级锁。
获取共享锁时,如果该数据被其他事务的排它锁锁住,则无法获取,需要等待排它锁释放。

意向锁

作用范围

意向锁为表锁
,在获取表锁之前,一定会检查意向锁。

意图锁定协议如下:

在事务获得表中某行的共享锁之前,它必须首先获得表上的 IS 锁或更强的锁。

在事务获得表中行的排他锁之前,它必须首先获得表的 IX 锁。

在获取任意表锁的共享锁或排它锁之前,一定会检查该表上的共享锁。

表锁以及意向锁的互斥规则如下:
X IX S IS
X Conflict Conflict Conflict Conflict
IX Conflict Compatible Conflict Compatible
S Conflict Conflict Compatible Compatible
IS Conflict Compatible Compatible Compatible

意向锁的作用在于:在获取表锁时,可以通过意向锁来快速判断能否获取。

因为获取行级锁时,会先获取对应的意向锁,这样另外的事务在获取表锁时就可以通过意向锁快速的判断,而不需要每行去扫描。

特别注意的是,意向锁是可以叠加的,即会存在多个,如T1事务获取了意向锁IX1和行级锁X1,T2事务依旧可以获取意向锁IX2和行级锁X2,所以仅在获取表级锁之前,才会检查意向锁。

记录锁

记录锁生效在索引上,用以在SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE时保护该行数据不被其他事务更改。

记录锁在没有索引时依旧会生效,因为innodb会为每张表创建一个隐藏的索引。

记录锁是最基本的行锁。

间隙锁

间隙锁生效在索引上,用于锁定索引值后的行,防止插入,在select from table where index=? for update时会生效,例如index=1,则会锁住index=1索引节点相关的行,
防止其他事务插入数据

但是并不会防止update语句,哪怕update的数据不存在。

Next-Key Locks

这个锁是记录锁和间隙锁的组合,简而言之在select from table where index=? for update时,既会有间隙锁防止insert,也会有记录锁在index上防止这一条数据的update和delete。这个Next-key只是对这两种锁的一种概括,因为这两种锁在select for update时通常会一起出现。

Insert Intention Locks

插入意向锁,和意向锁类似。不过是特殊的间隙锁,并不发生在select for update,而是在同时发生insert时产生,例如在两个事务同时insert索引区间为[4,7]时,同时获得该区间的意向锁,此时事务不会阻塞,例如A:insert-5,B:insert-7,此时不会阻塞两个事务。

插入意向锁是一个特殊的间隙锁,是为了防止正常间隙锁锁区间的情况下,insert频繁阻塞而设计的,例如A:insert-5,B:insert-7,如果没有插入意向锁,那么5和7都要去尝试获取间隙锁,此时第二个事务就会被阻塞,但是通过插入意向锁,第二个事务就不会被阻塞,只有到插入的行确实冲突,才会被阻塞。

AUTO-INC Locks

自增锁,这个锁很明显是表级insert锁,为了保证自增主键的表的主键保持原子自增。

对于锁这个东西,大家应该多去理解各种锁设计运行的原理和模型,这样在加深理解后,在使用起来才会更加深入和透彻。

常见锁使用的场景和用法

double check

众所周知,mysql的事务对防止重复插入并没有什么卵用,唯一索引又存在很多缺点,业务上最好不要使用,所以一般来说防止重复插入的通用做法就是使用分布式锁,这就有一种比较常用的写法。

final WeekendNoticeReadCountDO weekendNoticeReadCountDO = weekendNoticeReadRepositoryService.selectByNoticeId(noticeRequestDTO.getNoticeId());
if (weekendNoticeReadCountDO == null) {
    final String lockKey = RedisConstant.LOCK_WEEKEND_READ_COUNT_INSERT + ":" + noticeRequestDTO.getNoticeId();
    ClusterLock lock = clusterLockFactory.getClusterLockRedis(
        RedisConstant.REDIS_KEY_PREFIX,
        lockKey
    );
    if (lock.acquire(RedisConstant.REDIS_LOCK_DEFAULT_TIMEOUT)) {
        //double check
        final WeekendNoticeReadCountDO weekendNoticeReadCountDO = weekendNoticeReadRepositoryService.selectByNoticeId(noticeRequestDTO.getNoticeId());
        if (weekendNoticeReadCountDO == null) {
            try {
                lock.execute(() -> {
                    WeekendNoticeReadCountDO readCountDO = new WeekendNoticeReadCountDO();
                    readCountDO.setNoticeId(noticeRequestDTO.getNoticeId());
                    readCountDO.setReadCount(1L);
                    readCountDO.setCreateTime(new Date());
                    readCountDO.setUpdateTime(new Date());
                    weekendNoticeReadRepositoryService.insert(readCountDO);
                    return true;
                });
            } catch (ApiException err) {
                throw err;
            } catch (Exception e) {
                log.error("插入", e);
                throw new ApiException(ErrorEnum.SERVER_ERROR.getCode(), "服务端出错");
            }
        } else {
            weekendNoticeReadRepositoryService.noticeCountAdd(weekendNoticeReadCountDO);
        }
    } else {
        log.warn("redis锁获取超时,key:{}", lockKey);
        throw new ApiException(ErrorEnum.SERVER_ERROR.getCode(), "服务器繁忙,请稍后重试");
    }
} 

在获取到锁之后,可能是经过等待才获取到的锁,此时上一个释放锁的线程可能已经插入了数据了,所以在锁内部,依旧要再次校验一下数据是否存在。
这种写法适合大多数需要唯一性的写场景。

避免死锁

如何避免死锁?最简单有效的方法就是:**不要在锁里再去获取锁,简而言之就是锁最好单独使用,不要套娃。
也要注意一些隐性锁,比如数据库。
事务A:

  1. 插入[5,7],插入意向锁。
  2. select for update更新[100,150],间隙锁。
    事务B:
  3. select for update更新[90,120],间隙锁。
  4. 插入[4,6],插入意向锁。

此时在并发场景下,就可能会出现A持有了[5,7]的间隙锁,在等待事务B[90,120]的间隙锁,事务B也一样,就死锁了。
**

顺带谈谈并发场景下常见的问题

读写混乱

在写业务代码,定义一些工具类或者缓存类的时候,很容易疏忽而发生类似的问题。
比如构建一个static缓存,没有使用ConcurrentHashMap中的putIfAbsent等方法,也没有加锁去构建,导致上面的线程刚put了,下面的线程就删掉了,或者重复构建2次缓存。

Redis或者一些并发操作释放锁或者资源,没有检查是否是当前线程持有

这点在Redis锁的示例代码也讲到了。
线程A获取到锁,此时B,C在等待,然后A执行时间过长,导致锁超时被自动释放了,此时B获取到了锁,在快乐的执行,然后A执行完了之后,释放锁时没有判断是否还是自己持有,导致B持有的锁被删除了,此时C又获取到了锁,BC同时在执行。

AI人工智能简史

最近学习AI,顺便整理了一份AI人工智能简史,大家参考:

  • 1951年 第一台神经网络机,称为SNARC;
  • 1956年 达特茅斯学院会议,正式确立了人工智能的研究领域;
  • 1966年 MIT发明ELIZA人机心理治疗对话程序,通过关键词和数据库实现心理咨询;
  • 1980年 CMU为DEC设计的XCON专家系统获得巨大的成功;
  • 1997年 IBM深蓝击败国际象棋世界冠军卡斯帕罗夫,IBM在比赛后宣布深蓝退役;
  • 2010年 DeepMind成立;
  • 2011年 IBM Watson问答系统,赢得智力竞赛节目Jeopardy一等奖100万美元;
    • 苹果发布Siri智能语音助手;
    • Automated Insights推出智能写作产品并在美联社应用;
  • 2012年 谷歌发布Google Now安卓语音助手;
    • 谷歌自动驾驶汽车获得政府执照;
    • 亚马逊仓储机器人;
  • 2014年 PrincetonAI在图灵测试中取得了33%的成功率;
    • 亚马逊发布ECHO智能音箱,内置亚马逊Alexa智能助理;
    • 特斯拉发布Autopilot辅助驾驶;
    • 谷歌收购DeepMind;
    • 微软亚洲研究院推出小冰聊天机器人;同年,小冰微信版被腾讯下线;
  • 2015年 Ilya Sutskever和Greg Brockman创立openai,Elon Musk等担任主席;
  • 2016年 Google DeepMind AlphaGo以4:1击败顶尖职业棋手李世石,同年使用Master的匿名身份以全胜战绩击败中韩日台60名一流高手;
    • 谷歌发布Allo,其内置虚拟助手Google Assistant,提供智能回复功能;
    • 一款叫Prisma的照片编辑器App火爆全网;
    • Google翻译引入了深度学习的最新算法;
    • 特斯拉佛罗里达自动驾驶车祸致死事件;
    • 亚马逊开办无人超市amazon go!
  • 2017年 AlphaGo以全胜战绩击败围棋世界冠军柯洁,AlphaGo退役;
    • 小米发布小爱同学智能音箱;
    • 阿里巴巴发布天猫精灵智能音箱;
    • 各种智能助理产品被人们称为人工智障;
  • 2019年 微软向openai投资10亿美元;
  • 2021年 github发布GitHub Copilot,可以根据注释或者提示自动生成实现代码;
  • 2022年 openai在11月发布了号称可以接住一切话题的、基于GPT-3大型语言模型(LLM)的聊天AI产品chatGPT,引发AI热潮;
  • 2023年 openai发布了chatGPT4;
    • 微软Office和bing进行了服务整合,推出new bing和office copilot;
    • 微软向OpenAI LP提供了第二笔多年期投资,据报道为100亿美元;
    • Google推出bard,发布会演示中出现错误,谷歌的股票随后下跌了8%,相当于市值损失1000亿美元;
    • 百度发布文心一言,国内多家公司纷纷跟进自己的AI产品,但和chatGPT相比差距明显;

1966年MIT的ELIZA人机心理治疗对话程序,才采用关键词提取的方法,再用关键词去匹配相应的回答,实现心理咨询的功能。从产品落地,到现在一共五十多年。今天,chatGPT4已经可以深刻理解人类的自然语言,并可以针对各行各业的常规问题给出正确的理解和回答。chatGPT发布短短几个月,却已经深刻改变了人们工作和生活的方式,利用好这个工具,将可以极大地提高工作的效率和质量,你甚至可以就生活中的琐事向它咨询正确的处理方法,它就像一个无所不知的高级贴身私人顾问,随时准备着为你解答每一个疑惑。

人工智能的发展经历过多次高潮和低谷,这里又整理了一份另一个视角的AI发展的简史:

  • 人工智能的诞生:1943~1956
    • 20世纪40~50年代,一批来自不同领域的科学家开始探讨制造人工大脑的可能性。1956年,人工智能被确立为一门学科;
    • 这个时期,出现了控制论、信息论、计算理论等相关理论,暗示了构建电子大脑的可能性;
    • 1950年,图灵提出图灵测试;
    • 1951年第一台神经网络机出现,称为SNARC;
    • 1951年西洋跳棋(checkers)程序已经可以挑战具有相当水平的业余爱好者
    • 1955年,“逻辑理论家(Logic Theorist)程序能够证明《数学原理》中前52个定理中的38个;
    • 1956年,达特茅斯会议,AI的诞生;
  • 黄金年代:1956~1974
    • 使用搜索式推理做为AI的基础算法,使用启发式算法缩小搜索范围;
    • 通过自然语言进行交流;
    • 1966年,MIT第一个发明聊天机器人ELIZA人机心理治疗对话程序,用户有时会误以为自己是在和人交谈;
    • 1968年,阿瑟·克拉克在《“2001太空漫游”》中塑造了顶级AI:HAL9000;
  • 第一次AI低谷:1974~1980
    • AI研究者们对其课题的难度未能作出正确判断,导致投资缩减和消失;
    • 马文·闵斯基对感知器的激烈批评,导致神经网络销声匿迹了十年;
    • AI研究者们遭遇了无法克服的基础性障碍,如运算能力不足、莫拉维克悖论等;
  • 繁荣:1980~1987
    • 一类名为“专家系统”的AI程序开始为全世界的公司所采纳;
    • 1980年CMU为DEC设计的XCON专家系统获得巨大的成功;
    • 1981年日本建设第五代计算机项目,目标是造出可以与人对话的机器;
    • 神经网络重获新生;
  • 第二次AI低谷:1987~1993
    • 控制论复兴。一些研究者相信机器必须具有躯体,通过与世界的交互获得智能;
    • XCON暴露出诸多问题,例如脆弱、难用、维护费过高;
    • “第五代工程”并没有实现;
    • 对AI的资助被大幅削减;
  • 低调发展:1993~2022
    • 商业领域里,AI的声誉已经不如往昔;
    • AI比以往的任何时候都更加谨慎;
    • 1997年深蓝战胜国际象棋世界冠军卡斯帕罗夫;
    • 2005年,一台机器人在一条沙漠小径上成功地自动行驶了131英里,赢得了DARPA挑战大赛头奖;
    • AI已成为一门更严格的科学分支,1988年概率论和决策理论引入AI;
    • AI的许多伟大创新仅被看作计算机科学工具箱中的一件工具;
    • 人工智能的说法依然被回避使用;
    • 深度学习,大数据被广泛应用;
    • 强化学习与大语言模型
  • 通用人工智能元年:2023~至今
    • 以chatGPT为代表的大语言模型真正实现了人际对话,AI可以在相当宽泛的范围内准确理解常规自然语言含义并给出相应的回答;

人工智能发展至今,经历了几个起伏。从早期的理论发展,到后来的低谷期,再到近年来的蓬勃发展,人工智能取得了巨大进步。
神经网络带来的深度学习浪潮,使机器翻译、图像识别等一个个难题落地生根,AlphaGo的诞生标志着人工智能在博弈游戏领域获胜,GPT-3等语言模型的到来使人工智能在自然语言处理领域大放异彩。
然而,人工智能还远未达到人类智能的高度。要实现像人一样广泛地理解世界并进行复杂的推理,还需要在感知、常识推理和社会情感等方面取得突破。人工智能也面临偏见和隐私等挑战。
人工智能发展之路任重道远。未来,人工智能会走向哪里? 与人类的关系会如何?这还是未知。但人工智能已然改变世界,也必将继续改变世界。
让我们拭目以待,人工智能更惊人的未来。